diff --git a/pom.xml b/pom.xml index eb3163062f..ff5461ac0b 100644 --- a/pom.xml +++ b/pom.xml @@ -415,11 +415,38 @@ pom import + + + 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}. + * + *

Registered in {@code + * META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier}. + */ +public class ConsentJsonSchemaValidatorSupplier implements JsonSchemaValidatorSupplier { + + @Override + public JsonSchemaValidator get() { + return new ConsentJsonSchemaValidator(); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapper.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapper.java new file mode 100644 index 0000000000..63c8d93c1f --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapper.java @@ -0,0 +1,68 @@ +package org.broadinstitute.consent.http.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import java.io.IOException; + +/** + * Network-free {@link McpJsonMapper} implementation backed by Jackson's {@link ObjectMapper}. + * + *

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. + * + *

Registered in {@code META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier}. + */ +public class ConsentMcpJsonMapperSupplier implements McpJsonMapperSupplier { + + @Override + public McpJsonMapper get() { + return new ConsentMcpJsonMapper(new ObjectMapper()); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java new file mode 100644 index 0000000000..46f6a0d6d2 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java @@ -0,0 +1,43 @@ +package org.broadinstitute.consent.http.mcp; + +import io.dropwizard.lifecycle.Managed; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dropwizard {@link Managed} wrapper for {@link McpStatelessSyncServer}. + * + *

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: + * + *
    + *
  1. Resolves the calling {@link org.broadinstitute.consent.http.models.DuosUser} from the MCP + * transport context via {@link McpAuthHelper}. + *
  2. 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. + *
  3. Invokes the method via reflection. + *
  4. 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 OBJECT_TYPE = new TypeReference<>() {}; + + private McpToolResults() {} + + /** + * Wrap any serialisable object as a successful tool result with structured (native JSON) content. + * + *

The object is first serialised to JSON via {@link GsonUtil#buildGson()} (preserving the REST + * layer's format), then parsed back to a generic Java structure that is placed in {@code + * structuredContent}. This avoids the double-encoded-string problem that arises when JSON is + * embedded as plain text. + * + * @param value any object serialisable by GsonUtil + * @return a non-error CallToolResult with structured content + */ + public static McpSchema.CallToolResult of(Object value) { + String json = GsonUtil.buildGson().toJson(value); + try { + Object parsed = OBJECT_MAPPER.readValue(json, OBJECT_TYPE); + return McpSchema.CallToolResult.builder().structuredContent(parsed).isError(false).build(); + } catch (Exception e) { + // Still use structuredContent in the fallback — returning addTextContent when the tool + // declares an outputSchema causes the SDK to reject the result entirely. + LOGGER.warn("Failed to parse tool result as structured content; wrapping as raw string", e); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("raw", json)) + .isError(false) + .build(); + } + } + + /** + * Wrap a plain text message as a successful tool result. + * + *

Uses {@code structuredContent} so the result is accepted by the SDK even when the tool + * declares an {@code outputSchema}. + * + * @param text the text to return + * @return a non-error CallToolResult with structured content + */ + public static McpSchema.CallToolResult ofText(String text) { + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("text", text)) + .isError(false) + .build(); + } + + /** + * Build an error result. + * + *

Uses {@code structuredContent} so the SDK does not reject the result when the tool declares + * an {@code outputSchema}. The {@code isError} flag signals to the MCP client that the call + * failed; the {@code error} field carries the human-readable reason. + * + * @param message a human-readable error description + * @return an error CallToolResult + */ + public static McpSchema.CallToolResult error(String message) { + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("error", message)) + .isError(true) + .build(); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java new file mode 100644 index 0000000000..725e616090 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java @@ -0,0 +1,305 @@ +package org.broadinstitute.consent.http.mcp; + +import io.dropwizard.auth.Auth; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.broadinstitute.consent.http.authentication.AuthorizationHelper; +import org.broadinstitute.consent.http.models.DuosUser; +import org.broadinstitute.consent.http.service.UserService; +import org.broadinstitute.consent.http.util.ConsentLogger; + +/** + * Scans JAX-RS resource instances for {@link McpTool}-annotated methods and produces {@link + * McpStatelessServerFeatures.SyncToolSpecification} objects that can be registered directly with + * {@link io.modelcontextprotocol.server.McpStatelessSyncServer}. + * + *

Input schema construction

+ * + * For each annotated method the scanner inspects the method's parameters: + * + *
    + *
  • {@code @Auth} parameters are mapped to the resolved caller — they are not exposed in the + * MCP input schema. + *
  • {@code @PathParam} parameters are added as required string/integer properties. + *
  • {@code @QueryParam} parameters are added as optional properties. + *
+ * + * Any {@link McpToolParam} entries in {@link McpTool#params()} whose name matches one of the above + * JAX-RS params will override the auto-generated description and required flag. Entries + * with names that do not match any JAX-RS param are added as additional properties. + * + *

Handler auto-generation

+ * + * The generated handler resolves a {@link DuosUser} from the transport context, builds the method's + * argument array by matching each parameter annotation to the MCP arguments map or the resolved + * user, invokes the resource method via reflection, and wraps the result with {@link + * McpToolResults#of}. + */ +public final class McpToolScanner implements ConsentLogger { + + // nosemgrep + private static final String DESCRIPTION = "description"; + private final AuthorizationHelper authorizationHelper; + private final UserService userService; + + public McpToolScanner(AuthorizationHelper authorizationHelper, UserService userService) { + this.authorizationHelper = authorizationHelper; + this.userService = userService; + } + + /** + * Scans each resource instance for {@link McpTool}-annotated methods and returns a tool + * specification for each one found. + * + * @param resources JAX-RS resource instances (typically obtained from Guice) + * @return list of tool specs, one per annotated method, in encounter order + */ + public List scan(Object... resources) { + List specs = new ArrayList<>(); + for (Object resource : resources) { + for (Method method : resource.getClass().getMethods()) { + McpTool annotation = method.getAnnotation(McpTool.class); + if (annotation != null) { + specs.add(buildSpec(resource, method, annotation)); + logInfo( + "Registered MCP tool: " + + annotation.name() + + " → " + + resource.getClass().getSimpleName() + + "." + + method.getName()); + } + } + } + return specs; + } + + // ── Spec construction ──────────────────────────────────────────────────────────────────────── + + private McpStatelessServerFeatures.SyncToolSpecification buildSpec( + Object resource, Method method, McpTool annotation) { + + // Index McpToolParam overrides/additions by name for quick lookup. + Map paramOverrides = new LinkedHashMap<>(); + for (McpToolParam p : annotation.params()) { + paramOverrides.put(p.name(), p); + } + + // Build the input schema by walking the method's parameter list. + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (Parameter param : method.getParameters()) { + PathParam pp = param.getAnnotation(PathParam.class); + QueryParam qp = param.getAnnotation(QueryParam.class); + + if (pp != null) { + String name = pp.value(); + String type = javaTypeToJsonType(param.getParameterizedType()); + String desc = + descriptionFor(name, paramOverrides, "Path parameter identifying the " + name); + boolean req = !paramOverrides.containsKey(name) || paramOverrides.get(name).required(); + properties.put(name, Map.of("type", type, DESCRIPTION, desc)); + if (req) { + required.add(name); + } + paramOverrides.remove(name); // consumed + } else if (qp != null) { + String name = qp.value(); + String type = javaTypeToJsonType(param.getParameterizedType()); + String desc = descriptionFor(name, paramOverrides, "Filter by " + name); + boolean req = paramOverrides.containsKey(name) && paramOverrides.get(name).required(); + properties.put(name, Map.of("type", type, DESCRIPTION, desc)); + if (req) { + required.add(name); + } + paramOverrides.remove(name); // consumed + } + // @Auth params are not exposed in the schema. + } + + // Any remaining McpToolParam entries are MCP-only additions not present on the method. + for (McpToolParam extra : paramOverrides.values()) { + properties.put(extra.name(), Map.of("type", extra.type(), DESCRIPTION, extra.description())); + if (extra.required()) { + required.add(extra.name()); + } + } + + McpSchema.JsonSchema inputSchema = + new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + required.isEmpty() ? null : required, + /* additionalProperties= */ null, + /* defs= */ null, + /* definitions= */ null); + + Map outputSchema = Map.of("type", annotation.outputType()); + + McpSchema.Tool tool = + McpSchema.Tool.builder() + .name(annotation.name()) + .description(annotation.description()) + .inputSchema(inputSchema) + .outputSchema(outputSchema) + .build(); + + return new McpStatelessServerFeatures.SyncToolSpecification( + tool, (context, request) -> invoke(resource, method, annotation.name(), context, request)); + } + + // ── Handler invocation ─────────────────────────────────────────────────────────────────────── + + private McpSchema.CallToolResult invoke( + Object resource, + Method method, + String toolName, + McpTransportContext context, + McpSchema.CallToolRequest request) { + try { + DuosUser duosUser = McpAuthHelper.resolveDuosUser(context, authorizationHelper, userService); + Map args = request.arguments() != null ? request.arguments() : Map.of(); + Object[] methodArgs = buildArgs(method, duosUser, args); + Object result = method.invoke(resource, methodArgs); + + if (result instanceof Response response) { + int status = response.getStatus(); + if (status >= 400) { + // Resource method returned an error response (e.g. 401, 403, 404, 500). + // Extract a readable message from the entity if possible; fall back to the status reason. + Object entity = response.getEntity(); + String message = entity != null ? String.valueOf(entity) : "HTTP " + status; + return McpToolResults.error(message); + } + Object entity = response.getEntity(); + if (entity == null) { + return McpToolResults.ofText("(no content)"); + } + return McpToolResults.of(entity); + } + return McpToolResults.of(result); + + } catch (jakarta.ws.rs.NotAuthorizedException | jakarta.ws.rs.NotFoundException e) { + return McpToolResults.error(e.getMessage()); + } catch (java.lang.reflect.InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof jakarta.ws.rs.NotAuthorizedException + || cause instanceof jakarta.ws.rs.NotFoundException) { + return McpToolResults.error(cause.getMessage()); + } + logException(cause != null ? (Exception) cause : e); + return McpToolResults.error( + "Unexpected error in " + + toolName + + ": " + + (cause != null ? cause.getMessage() : e.getMessage())); + } catch (Exception e) { + logException(e); + return McpToolResults.error("Unexpected error in " + toolName + ": " + e.getMessage()); + } + } + + /** Maps method parameters to the corresponding values from the resolved user or MCP args. */ + private static Object[] buildArgs(Method method, DuosUser duosUser, Map mcpArgs) { + Parameter[] params = method.getParameters(); + Object[] args = new Object[params.length]; + for (int i = 0; i < params.length; i++) { + Parameter param = params[i]; + if (param.isAnnotationPresent(Auth.class)) { + args[i] = duosUser; + } else if (param.isAnnotationPresent(PathParam.class)) { + String name = param.getAnnotation(PathParam.class).value(); + args[i] = coerce(mcpArgs.get(name), param.getParameterizedType()); + } else if (param.isAnnotationPresent(QueryParam.class)) { + String name = param.getAnnotation(QueryParam.class).value(); + Object raw = mcpArgs.get(name); + args[i] = raw != null ? coerce(raw, param.getParameterizedType()) : null; + } else { + // Body params, FormDataMultiPart, etc. — not supported; pass null. + args[i] = null; + } + } + return args; + } + + // ── Type helpers ───────────────────────────────────────────────────────────────────────────── + + /** + * Maps a Java generic type to its JSON Schema primitive type string. Handles common JAX-RS param + * types and basic generics (e.g. {@code List}). + */ + static String javaTypeToJsonType(Type type) { + if (type instanceof ParameterizedType pt) { + Type raw = pt.getRawType(); + if (raw == List.class || raw == java.util.Collection.class) { + return "array"; + } + } + if (type == Integer.class || type == int.class || type == Long.class || type == long.class) { + return "integer"; + } + if (type == Boolean.class || type == boolean.class) { + return "boolean"; + } + if (type == Double.class + || type == double.class + || type == Float.class + || type == float.class) { + return "number"; + } + return "string"; + } + + /** + * Coerces a raw MCP argument value (typically String or Number from JSON deserialisation) to the + * target Java type expected by the method parameter. + */ + static Object coerce(Object value, Type targetType) { + if (value == null) { + return null; + } + if (targetType == String.class) { + return String.valueOf(value); + } + if (targetType == Integer.class || targetType == int.class) { + return value instanceof Number n ? n.intValue() : Integer.parseInt(String.valueOf(value)); + } + if (targetType == Long.class || targetType == long.class) { + return value instanceof Number n ? n.longValue() : Long.parseLong(String.valueOf(value)); + } + if (targetType == Boolean.class || targetType == boolean.class) { + return value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value)); + } + if (targetType instanceof ParameterizedType pt && pt.getRawType() == List.class) { + // MCP args deserialise JSON arrays as List; cast elements to the list's type arg. + Type elementType = pt.getActualTypeArguments()[0]; + if (value instanceof List list) { + return list.stream().map(item -> coerce(item, elementType)).toList(); + } + // Single value wrapped in a list. + return List.of(coerce(value, elementType)); + } + return value; + } + + private static String descriptionFor( + String name, Map overrides, String defaultDesc) { + McpToolParam override = overrides.get(name); + return (override != null && !override.description().isBlank()) + ? override.description() + : defaultDesc; + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/resources/DatasetResource.java b/src/main/java/org/broadinstitute/consent/http/resources/DatasetResource.java index d5b0cde6a8..a53cb6b204 100644 --- a/src/main/java/org/broadinstitute/consent/http/resources/DatasetResource.java +++ b/src/main/java/org/broadinstitute/consent/http/resources/DatasetResource.java @@ -39,6 +39,8 @@ import org.broadinstitute.consent.http.cloudstore.GCSService; import org.broadinstitute.consent.http.enumeration.UserRoles; import org.broadinstitute.consent.http.exceptions.UnprocessableEntityException; +import org.broadinstitute.consent.http.mcp.McpTool; +import org.broadinstitute.consent.http.mcp.McpToolParam; import org.broadinstitute.consent.http.models.AuthUser; import org.broadinstitute.consent.http.models.DataUse; import org.broadinstitute.consent.http.models.Dataset; @@ -63,6 +65,7 @@ import org.glassfish.jersey.media.multipart.FormDataMultiPart; import org.glassfish.jersey.media.multipart.FormDataParam; +@SuppressWarnings("unused") @Path("api/dataset") public class DatasetResource extends Resource { @@ -227,14 +230,43 @@ public Response patchByDatasetUpdate( } } + @McpTool( + name = "dataset_search", + description = + """ + Search DUOS datasets and studies visible to the caller. + Returns dataset id, name, identifier, study name, and public visibility. + Provide a query string to filter by name; omit it to list all accessible datasets. + """, + outputType = "array", + params = { + @McpToolParam( + name = "query", + type = "string", + description = + "Case-insensitive text matched against dataset name and study name." + + " Omit to return all datasets visible to the caller.", + required = false) + }) @GET @Produces("application/json") @PermitAll @Path("/v3") - public Response findAllDatasetStudySummaries(@Auth DuosUser duosUser) { + public Response findAllDatasetStudySummaries( + @Auth DuosUser duosUser, @QueryParam("query") String query) { try { List summaries = datasetService.findAllDatasetStudySummaries(duosUser.getUser()); + if (query != null && !query.isBlank()) { + String q = query.strip().toLowerCase(); + summaries = + summaries.stream() + .filter( + d -> + (d.dataset_name() != null && d.dataset_name().toLowerCase().contains(q)) + || (d.study_name() != null && d.study_name().toLowerCase().contains(q))) + .toList(); + } return Response.ok(summaries).build(); } catch (Exception e) { return createExceptionResponse(e); @@ -573,6 +605,20 @@ public Response removeAuthorizedReaders( } } + @McpTool( + name = "dataset_approved_users", + description = + """ + Get the list of users approved to access a dataset. Returns an array user email addresses. + """, + outputType = "array", + params = { + @McpToolParam( + name = "identifier", + type = "string", + description = "The dataset identifier", + required = true) + }) @GET @Produces("application/json") @RolesAllowed(RESEARCHER) diff --git a/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier new file mode 100644 index 0000000000..f070b29c6f --- /dev/null +++ b/src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -0,0 +1 @@ +org.broadinstitute.consent.http.mcp.ConsentMcpJsonMapperSupplier diff --git a/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier b/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier new file mode 100644 index 0000000000..debf2c63e3 --- /dev/null +++ b/src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier @@ -0,0 +1 @@ +org.broadinstitute.consent.http.mcp.ConsentJsonSchemaValidatorSupplier diff --git a/src/main/resources/assets/paths/datasetV3.yaml b/src/main/resources/assets/paths/datasetV3.yaml index 8243681301..60cd107341 100644 --- a/src/main/resources/assets/paths/datasetV3.yaml +++ b/src/main/resources/assets/paths/datasetV3.yaml @@ -3,8 +3,19 @@ get: operationId: apiDatasetV3Get description: | Provides a list of datasets and their associated study information in a very minimal format. + Results are limited to datasets the caller is permitted to see. + Use the optional `query` parameter to filter by dataset name or study name. tags: - Dataset + parameters: + - name: query + in: query + required: false + description: > + Case-insensitive substring filter applied to dataset name and study name. + Omit to return all datasets visible to the caller. + schema: + type: string responses: 200: description: List of Dataset Study Summary objects diff --git a/src/test/java/org/broadinstitute/consent/http/resources/DatasetResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/DatasetResourceTest.java index 0aa016559f..0a3ab4c0cd 100644 --- a/src/test/java/org/broadinstitute/consent/http/resources/DatasetResourceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/resources/DatasetResourceTest.java @@ -735,7 +735,7 @@ void testUpdateDatasetDataUse_NotModified() { void testFindAllDatasetStudySummaries() { when(datasetService.findAllDatasetStudySummaries(user)).thenReturn(List.of()); - try (var response = resource.findAllDatasetStudySummaries(duosUser)) { + try (var response = resource.findAllDatasetStudySummaries(duosUser, null)) { assertEquals(HttpStatusCodes.STATUS_CODE_OK, response.getStatus()); } }