diff --git a/pom.xml b/pom.xml
index eb3163062f..ff5461ac0b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -415,11 +415,38 @@
pomimport
+
+
+ io.netty
+ netty-bom
+ ${netty.override.version}
+ pom
+ import
+
+
+
+ io.modelcontextprotocol.sdk
+ mcp
+ 0.14.1
+
+
+
+ io.modelcontextprotocol.sdk
+ mcp-json-jackson2
+
+
+
+
io.swagger.parser.v3
diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java
index a486121ef9..cc526e98ca 100644
--- a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java
+++ b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java
@@ -14,13 +14,17 @@
import io.dropwizard.core.setup.Environment;
import io.dropwizard.forms.MultiPartBundle;
import io.dropwizard.jdbi3.bundles.JdbiExceptionsBundle;
+import io.modelcontextprotocol.server.McpStatelessSyncServer;
+import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import io.sentry.Sentry;
import io.sentry.SentryLevel;
+import jakarta.servlet.DispatcherType;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.text.MessageFormat;
+import java.util.EnumSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -47,6 +51,8 @@
import org.broadinstitute.consent.http.health.GCSHealthCheck;
import org.broadinstitute.consent.http.health.SamHealthCheck;
import org.broadinstitute.consent.http.health.SendGridHealthCheck;
+import org.broadinstitute.consent.http.mcp.ConsentMcpManaged;
+import org.broadinstitute.consent.http.mcp.McpClaimsFilter;
import org.broadinstitute.consent.http.models.AuthUser;
import org.broadinstitute.consent.http.models.DuosUser;
import org.broadinstitute.consent.http.resources.DACAutomationRuleResource;
@@ -141,6 +147,20 @@ public void run(ConsentConfiguration config, Environment env) {
System.setProperty("sun.net.http.allowRestrictedHeaders", "true");
env.jersey().register(JerseyGsonProvider.class);
+ // MCP stateless endpoint (POST /mcp)
+ // McpClaimsFilter must be registered BEFORE the servlet so it runs first.
+ // It reads OAUTH2_CLAIM_* headers set by Apache mod_oauth2 and populates ClaimsCache,
+ // mirroring what RequestHeaderCacheFilter does for Jersey requests on /api.
+ HttpServletStatelessServerTransport mcpTransport =
+ injector.getInstance(HttpServletStatelessServerTransport.class);
+ env.servlets()
+ .addFilter("mcp-claims-filter", new McpClaimsFilter())
+ .addMappingForUrlPatterns(
+ EnumSet.of(DispatcherType.REQUEST), /* isMatchAfterFilter= */ false, "/mcp");
+ env.servlets().addServlet("mcp-stateless", mcpTransport).addMapping("/mcp");
+ McpStatelessSyncServer mcpServer = injector.getInstance(McpStatelessSyncServer.class);
+ env.lifecycle().manage(new ConsentMcpManaged(mcpServer));
+
// Metric Registry
MetricRegistry metricRegistry = new MetricRegistry();
env.jersey().register(new InstrumentedResourceMethodApplicationListener(metricRegistry));
diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java
index 3938bd99c9..f958194999 100644
--- a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java
+++ b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java
@@ -4,10 +4,15 @@
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.Provides;
+import com.google.inject.Singleton;
import io.dropwizard.client.JerseyClientBuilder;
import io.dropwizard.core.Configuration;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.jdbi3.JdbiFactory;
+import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.server.McpServer;
+import io.modelcontextprotocol.server.McpStatelessSyncServer;
+import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport;
import jakarta.ws.rs.client.Client;
import org.broadinstitute.consent.http.authentication.AuthorizationHelper;
import org.broadinstitute.consent.http.authentication.DuosUserAuthenticator;
@@ -47,6 +52,8 @@
import org.broadinstitute.consent.http.db.VoteDAO;
import org.broadinstitute.consent.http.mail.SendGridAPI;
import org.broadinstitute.consent.http.mail.freemarker.FreeMarkerTemplateHelper;
+import org.broadinstitute.consent.http.mcp.ConsentMcpJsonMapper;
+import org.broadinstitute.consent.http.mcp.ConsentMcpToolProvider;
import org.broadinstitute.consent.http.service.AcknowledgementService;
import org.broadinstitute.consent.http.service.CounterService;
import org.broadinstitute.consent.http.service.DACAutomationRuleService;
@@ -703,4 +710,47 @@ FeatureFlagDAO providesFeatureFlagDAO() {
OntologyDAO providesOntologyDAO() {
return ontologyDAO;
}
+
+ // ── MCP ──────────────────────────────────────────────────────────────────────────────────────
+
+ @Provides
+ @Singleton
+ HttpServletStatelessServerTransport providesMcpTransport() {
+ // HttpServletStatelessServerTransport (SDK 0.14.x non-deprecated transport):
+ // .jsonMapper(McpJsonMapper) – ConsentMcpJsonMapper (networknt-free Jackson impl)
+ // .messageEndpoint(String) – single endpoint for all MCP traffic (POST /mcp)
+ // .contextExtractor(extractor) – captures Bearer token per-request into
+ // McpTransportContext
+ //
+ // The contextExtractor stores the Bearer token under key "bearer". Tool handlers receive the
+ // McpTransportContext as their first BiFunction parameter and call McpAuthHelper.resolveUser()
+ // to authenticate the caller without relying on ThreadLocal propagation.
+ return HttpServletStatelessServerTransport.builder()
+ .jsonMapper(new ConsentMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper()))
+ .messageEndpoint("/mcp")
+ .contextExtractor(
+ req -> {
+ String auth = req.getHeader("Authorization");
+ String bearer = (auth != null && auth.startsWith("Bearer ")) ? auth.substring(7) : "";
+ return McpTransportContext.create(java.util.Map.of("bearer", bearer));
+ })
+ .build();
+ }
+
+ @Provides
+ @com.google.inject.Singleton
+ McpStatelessSyncServer providesMcpServer(
+ HttpServletStatelessServerTransport transport, ConsentMcpToolProvider toolProvider) {
+ // McpServer.sync() is overloaded: sync(McpStatelessServerTransport) →
+ // StatelessSyncSpecification
+ // → McpStatelessSyncServer. This overload (and its return type) are not deprecated.
+ return McpServer.sync(transport)
+ .serverInfo("consent-mcp", "1.0.0")
+ .capabilities(
+ io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.builder()
+ .tools(/* listChanged= */ false)
+ .build())
+ .tools(toolProvider.allTools())
+ .build();
+ }
}
diff --git a/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java b/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java
index acf2891c30..ec32a95828 100644
--- a/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java
+++ b/src/main/java/org/broadinstitute/consent/http/authentication/AuthorizationHelper.java
@@ -37,6 +37,27 @@ protected Cache> getCache() {
return claimsCache.cache;
}
+ /**
+ * Resolve an AuthUser from a raw Bearer token. Looks up the token in the ClaimsCache (populated
+ * by RequestHeaderCacheFilter for Jersey requests, or McpClaimsFilter for MCP SSE requests), then
+ * builds the AuthUser from the cached OAUTH2_CLAIM headers.
+ *
+ * @param bearerToken raw token value without the "Bearer " prefix
+ * @return resolved AuthUser
+ * @throws NotAuthorizedException if the token is absent, blank, or not present in the cache
+ */
+ public AuthUser resolveAuthUser(String bearerToken) {
+ if (bearerToken == null || bearerToken.isBlank()) {
+ throw new NotAuthorizedException("Missing Bearer token");
+ }
+ Map headers = getCache().getIfPresent(bearerToken);
+ if (headers == null) {
+ throw new NotAuthorizedException(
+ "Token not recognized — ensure the /mcp path has AuthType oauth2 configured in Apache");
+ }
+ return buildAuthUserFromHeaders(headers);
+ }
+
protected AuthUser buildAuthUserFromHeaders(Map headers) {
String aud = headers.get(ClaimsCache.OAUTH2_CLAIM_aud);
String token = headers.get(ClaimsCache.OAUTH2_CLAIM_access_token);
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidator.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidator.java
new file mode 100644
index 0000000000..095920ff16
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidator.java
@@ -0,0 +1,24 @@
+package org.broadinstitute.consent.http.mcp;
+
+import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
+import java.util.Map;
+
+/**
+ * No-op {@link JsonSchemaValidator} that accepts all tool-call inputs without validation.
+ *
+ *
The SDK requires a {@link JsonSchemaValidator} via ServiceLoader. The default implementation
+ * ({@code DefaultJsonSchemaValidator} in {@code mcp-json-jackson2}) uses {@code
+ * com.networknt:json-schema-validator 1.5.7}, which conflicts with the project's 3.0.2 pin. We
+ * register this pass-through instead.
+ *
+ *
MCP tool schemas in this service are simple Maps used for documentation only; runtime
+ * validation of tool arguments is not required.
+ */
+public class ConsentJsonSchemaValidator implements JsonSchemaValidator {
+
+ @Override
+ public ValidationResponse validate(Map schema, Object structuredContent) {
+ // Pass through — tool-argument validation is not required for DUOS MCP tools.
+ return ValidationResponse.asValid(String.valueOf(structuredContent));
+ }
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidatorSupplier.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidatorSupplier.java
new file mode 100644
index 0000000000..9a7c5b9167
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidatorSupplier.java
@@ -0,0 +1,18 @@
+package org.broadinstitute.consent.http.mcp;
+
+import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
+import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier;
+
+/**
+ * ServiceLoader registration for {@link JsonSchemaValidator}.
+ *
+ *
The SDK ships {@code mcp-json-jackson2} as the default Jackson adapter, but that module
+ * registers a {@code JacksonJsonSchemaValidatorSupplier} via ServiceLoader which depends on {@code
+ * com.networknt:json-schema-validator 1.5.7}. This project pins 3.0.2, whose API is incompatible
+ * (the {@code SpecVersion$VersionFlag} inner class was removed), causing a {@link
+ * NoClassDefFoundError} at startup.
+ *
+ *
This implementation provides only the JSON serialisation/deserialisation that the MCP server
+ * transport and tool dispatching actually need — no schema validation. {@code mcp-json-jackson2} is
+ * excluded from the Maven dependency tree entirely.
+ */
+public class ConsentMcpJsonMapper implements McpJsonMapper {
+
+ private final ObjectMapper mapper;
+
+ public ConsentMcpJsonMapper(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public T readValue(String content, Class type) throws IOException {
+ return mapper.readValue(content, type);
+ }
+
+ @Override
+ public T readValue(byte[] content, Class type) throws IOException {
+ return mapper.readValue(content, type);
+ }
+
+ @Override
+ public T readValue(String content, TypeRef type) throws IOException {
+ return mapper.readValue(content, mapper.getTypeFactory().constructType(type.getType()));
+ }
+
+ @Override
+ public T readValue(byte[] content, TypeRef type) throws IOException {
+ return mapper.readValue(content, mapper.getTypeFactory().constructType(type.getType()));
+ }
+
+ @Override
+ public T convertValue(Object fromValue, Class type) {
+ return mapper.convertValue(fromValue, type);
+ }
+
+ @Override
+ public T convertValue(Object fromValue, TypeRef type) {
+ return mapper.convertValue(fromValue, mapper.getTypeFactory().constructType(type.getType()));
+ }
+
+ @Override
+ public String writeValueAsString(Object value) throws IOException {
+ return mapper.writeValueAsString(value);
+ }
+
+ @Override
+ public byte[] writeValueAsBytes(Object value) throws IOException {
+ return mapper.writeValueAsBytes(value);
+ }
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapperSupplier.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapperSupplier.java
new file mode 100644
index 0000000000..bda761f085
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapperSupplier.java
@@ -0,0 +1,24 @@
+package org.broadinstitute.consent.http.mcp;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.McpJsonMapperSupplier;
+
+/**
+ * ServiceLoader registration for {@link McpJsonMapper}.
+ *
+ *
The SDK discovers a default {@link McpJsonMapper} via {@code
+ * ServiceLoader} when {@link McpJsonMapper#getDefault()} is called
+ * internally. Because we excluded {@code mcp-json-jackson2} (to avoid its transitive dependency on
+ * {@code com.networknt:json-schema-validator 1.5.7} which conflicts with the project's 3.0.2 pin),
+ * we register this supplier instead so the SDK can find a mapper at runtime.
+ *
+ *
Dropwizard calls {@link #start()} after all resources are registered and the HTTP server is
+ * running, and calls {@link #stop()} during graceful shutdown before the JVM exits.
+ *
+ *
{@code McpStatelessSyncServer} has no explicit {@code start()} method — it begins serving as
+ * soon as the transport servlet receives connections. {@code closeGracefully()} handles shutdown.
+ */
+public class ConsentMcpManaged implements Managed {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ConsentMcpManaged.class);
+
+ private final McpStatelessSyncServer server;
+
+ public ConsentMcpManaged(McpStatelessSyncServer server) {
+ this.server = server;
+ }
+
+ @Override
+ public void start() {
+ // McpStatelessSyncServer starts automatically when the transport servlet receives connections.
+ LOGGER.info("Consent MCP server ready");
+ }
+
+ @Override
+ public void stop() {
+ LOGGER.info("Stopping Consent MCP server");
+ try {
+ server.closeGracefully();
+ LOGGER.info("Consent MCP server stopped");
+ } catch (Exception e) {
+ LOGGER.warn("MCP server did not shut down cleanly", e);
+ }
+ }
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java
new file mode 100644
index 0000000000..6ac3a57ab5
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java
@@ -0,0 +1,50 @@
+package org.broadinstitute.consent.http.mcp;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import io.modelcontextprotocol.server.McpStatelessServerFeatures;
+import java.util.List;
+import org.broadinstitute.consent.http.authentication.AuthorizationHelper;
+import org.broadinstitute.consent.http.resources.DatasetResource;
+import org.broadinstitute.consent.http.service.UserService;
+import org.broadinstitute.consent.http.util.ConsentLogger;
+
+/**
+ * Assembles all MCP tool specifications for the Consent MCP server by delegating to {@link
+ * McpToolScanner}.
+ *
+ *
To expose a resource method as an MCP tool, annotate it with {@link McpTool} (and optionally
+ * {@link McpToolParam} for parameter metadata), then add the resource instance to the {@link
+ * McpToolScanner#scan} call in {@link #allTools()}.
+ *
+ *
{@link McpToolScanner} auto-generates the tool spec (name, description, input/output schema)
+ * from the annotation and the method's JAX-RS parameter annotations ({@code @PathParam},
+ * {@code @QueryParam}). The handler resolves the caller as a {@link
+ * org.broadinstitute.consent.http.models.DuosUser}, invokes the resource method via reflection, and
+ * wraps the result with {@link McpToolResults#of}.
+ */
+@Singleton
+public class ConsentMcpToolProvider implements ConsentLogger {
+
+ private final McpToolScanner scanner;
+ private final DatasetResource datasetResource;
+
+ @Inject
+ public ConsentMcpToolProvider(
+ AuthorizationHelper authorizationHelper,
+ UserService userService,
+ DatasetResource datasetResource) {
+ this.scanner = new McpToolScanner(authorizationHelper, userService);
+ this.datasetResource = datasetResource;
+ }
+
+ /**
+ * Returns all registered MCP tool specifications.
+ *
+ *
To add new tools: annotate the target resource method with {@link McpTool}, then add its
+ * resource instance to the {@link McpToolScanner#scan} argument list below.
+ */
+ public List allTools() {
+ return scanner.scan(datasetResource);
+ }
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java
new file mode 100644
index 0000000000..aafb24f09c
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java
@@ -0,0 +1,51 @@
+package org.broadinstitute.consent.http.mcp;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import org.broadinstitute.consent.http.authentication.AuthorizationHelper;
+import org.broadinstitute.consent.http.models.AuthUser;
+import org.broadinstitute.consent.http.models.DuosUser;
+import org.broadinstitute.consent.http.models.User;
+import org.broadinstitute.consent.http.service.UserService;
+
+/**
+ * Authentication helper for MCP tool handlers.
+ *
+ *
MCP tool calls arrive as HTTP POST requests to /mcp. McpClaimsFilter intercepts each request,
+ * populates ClaimsCache from the OAUTH2_CLAIM_* headers set by Apache mod_oauth2. The transport's
+ * contextExtractor (configured in ConsentModule) captures the raw Bearer token from the request and
+ * stores it in {@link McpTransportContext} under the key {@code "bearer"}.
+ *
+ *
Tool handlers (and the auto-generated handlers in {@link McpToolScanner}) call {@link
+ * #resolveDuosUser} to obtain a {@link DuosUser} that can be passed directly to resource methods
+ * annotated with {@link McpTool}.
+ */
+public final class McpAuthHelper {
+
+ private McpAuthHelper() {}
+
+ /**
+ * Resolve the calling user as a {@link DuosUser} for use with JAX-RS resource method invocation.
+ *
+ *
Reads the Bearer token from the supplied {@link McpTransportContext}, looks it up in
+ * ClaimsCache via {@link AuthorizationHelper#resolveAuthUser(String)}, fetches the fully
+ * populated {@link User} (with roles), and wraps both in a {@link DuosUser} — the type that
+ * resource methods receive via {@code @Auth DuosUser}.
+ *
+ * @param context the transport context propagated to this tool handler
+ * @param authorizationHelper to resolve AuthUser from the cached claims
+ * @param userService to load the full User record including roles
+ * @return a DuosUser combining the AuthUser and the fully populated User
+ * @throws jakarta.ws.rs.NotAuthorizedException if the token is missing or not in the cache
+ * @throws jakarta.ws.rs.NotFoundException if the email from the token is not registered in DUOS
+ */
+ public static DuosUser resolveDuosUser(
+ McpTransportContext context,
+ AuthorizationHelper authorizationHelper,
+ UserService userService) {
+ Object bearerObj = context.get("bearer");
+ String bearer = bearerObj != null ? String.valueOf(bearerObj) : null;
+ AuthUser authUser = authorizationHelper.resolveAuthUser(bearer);
+ User user = userService.findUserByEmail(authUser.getEmail());
+ return new DuosUser(authUser, user);
+ }
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpClaimsFilter.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpClaimsFilter.java
new file mode 100644
index 0000000000..81c8a6fe98
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpClaimsFilter.java
@@ -0,0 +1,59 @@
+package org.broadinstitute.consent.http.mcp;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.FilterConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MultivaluedHashMap;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Enumeration;
+import org.broadinstitute.consent.http.filters.ClaimsCache;
+
+/**
+ * Jakarta Servlet Filter registered on /mcp/* that mirrors what RequestHeaderCacheFilter does for
+ * Jersey requests. Apache mod_oauth2 (AuthType oauth2 on the /mcp Location block) validates the
+ * Bearer token and sets OAUTH2_CLAIM_* headers on every inbound request. This filter reads those
+ * headers and loads them into ClaimsCache, keyed by the raw Bearer token. McpAuthHelper then reads
+ * from the same cache when resolving a caller inside a tool handler.
+ *
+ *
The Bearer token itself is propagated to tool handlers via the transport provider's
+ * contextExtractor (configured in ConsentModule), which stores it in McpTransportContext. This
+ * approach works correctly even when the SDK dispatches tool handlers on Reactor scheduler threads
+ * that are different from the original HTTP request thread.
+ */
+public class McpClaimsFilter implements Filter {
+
+ @Override
+ public void init(FilterConfig filterConfig) {}
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ if (request instanceof HttpServletRequest httpReq) {
+ String authHeader = httpReq.getHeader(HttpHeaders.AUTHORIZATION);
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ String bearer = authHeader.substring("Bearer ".length());
+ // Build a MultivaluedMap so we can reuse ClaimsCache.loadCache, which filters for
+ // headers whose names start with "OAUTH2_CLAIM" (set by Apache mod_oauth2).
+ MultivaluedHashMap headers = new MultivaluedHashMap<>();
+ Enumeration names = httpReq.getHeaderNames();
+ if (names != null) {
+ while (names.hasMoreElements()) {
+ String name = names.nextElement();
+ headers.addAll(name, Collections.list(httpReq.getHeaders(name)));
+ }
+ }
+ ClaimsCache.getInstance().loadCache(bearer, headers);
+ }
+ }
+ chain.doFilter(request, response);
+ }
+
+ @Override
+ public void destroy() {}
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpTool.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpTool.java
new file mode 100644
index 0000000000..627f007a0f
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpTool.java
@@ -0,0 +1,60 @@
+package org.broadinstitute.consent.http.mcp;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a JAX-RS resource method as an MCP tool.
+ *
+ *
{@link McpToolScanner} discovers all methods annotated with {@code @McpTool} in a set of
+ * resource instances and builds a {@link
+ * io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification} for each one.
+ *
+ *
Input schema
+ *
+ * The scanner auto-detects {@code @PathParam} and {@code @QueryParam} parameters from the method
+ * signature, mapping their Java types to JSON Schema types. Descriptions for these are taken from
+ * {@link #params()} if an entry with a matching {@link McpToolParam#name()} is present; otherwise a
+ * generic description is generated. Params listed in {@link #params()} with names that do not match
+ * any JAX-RS annotation are added as additional tool inputs (useful for MCP-only filtering that is
+ * also expressed as a {@code @QueryParam} on the method).
+ *
+ *
Handler auto-generation
+ *
+ * The scanner generates a handler that:
+ *
+ *
+ *
Resolves the calling {@link org.broadinstitute.consent.http.models.DuosUser} from the MCP
+ * transport context via {@link McpAuthHelper}.
+ *
Builds the method's argument array by matching {@code @Auth}, {@code @PathParam}, and
+ * {@code @QueryParam} parameters to the resolved user and the tool call's arguments map.
+ *
Invokes the method via reflection.
+ *
Extracts the entity from the returned {@link jakarta.ws.rs.core.Response} (or wraps a
+ * non-Response return value directly) and returns it as a {@link McpToolResults#of} result.
+ *
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface McpTool {
+
+ /** MCP tool name exposed to clients (e.g. {@code "dataset_search"}). Must be unique. */
+ String name();
+
+ /** Human-readable description used by MCP clients to decide when to invoke this tool. */
+ String description();
+
+ /**
+ * JSON Schema type for the tool's output, used to populate {@code outputSchema} on the tool spec.
+ * Common values: {@code "object"} (default) or {@code "array"}.
+ */
+ String outputType() default "object";
+
+ /**
+ * Parameter metadata. Entries whose {@link McpToolParam#name()} matches a {@code @PathParam} or
+ * {@code @QueryParam} on the method override the auto-generated description and required flag for
+ * that parameter. Entries with unmatched names are added as additional input properties.
+ */
+ McpToolParam[] params() default {};
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolParam.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolParam.java
new file mode 100644
index 0000000000..8ee72a34ba
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolParam.java
@@ -0,0 +1,29 @@
+package org.broadinstitute.consent.http.mcp;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Declares a single input parameter for an MCP tool.
+ *
+ *
Used within {@link McpTool#params()} to describe parameters that are either MCP-specific or
+ * whose descriptions should override the defaults that {@link McpToolScanner} would otherwise
+ * derive from the method's JAX-RS annotations ({@code @QueryParam}, {@code @PathParam}).
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({}) // only usable as a member of @McpTool
+public @interface McpToolParam {
+
+ /** JSON Schema property name. Must match the JAX-RS param name if overriding one. */
+ String name();
+
+ /** JSON Schema primitive type: "string", "integer", "boolean", "number". Default: "string". */
+ String type() default "string";
+
+ /** Human-readable description exposed to the MCP client. */
+ String description() default "";
+
+ /** Whether the MCP client must supply this parameter. Default: false (optional). */
+ boolean required() default false;
+}
diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java
new file mode 100644
index 0000000000..75c3c98424
--- /dev/null
+++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java
@@ -0,0 +1,86 @@
+package org.broadinstitute.consent.http.mcp;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.modelcontextprotocol.spec.McpSchema;
+import java.util.Map;
+import org.broadinstitute.consent.http.util.gson.GsonUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class for building {@link McpSchema.CallToolResult} values from domain objects.
+ *
+ *
All serialisation uses {@link GsonUtil#buildGson()} to stay consistent with the REST layer's
+ * JSON output format (date handling, enum names, etc.). The resulting JSON string is then parsed
+ * back into a generic Java structure (Map / List) and placed in {@code structuredContent} so that
+ * MCP clients receive a native JSON value rather than a double-encoded string.
+ */
+public final class McpToolResults {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(McpToolResults.class);
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final TypeReference