From b37909e7ddc1e428aa064b689e485aa194b7ecc5 Mon Sep 17 00:00:00 2001 From: rushtong Date: Tue, 5 May 2026 12:02:55 -0400 Subject: [PATCH 1/8] poc wip: first pass mcp implementation --- pom.xml | 27 ++++ .../consent/http/ConsentApplication.java | 18 +++ .../consent/http/ConsentModule.java | 49 +++++++ .../authentication/AuthorizationHelper.java | 21 +++ .../http/mcp/ConsentJsonSchemaValidator.java | 24 ++++ .../ConsentJsonSchemaValidatorSupplier.java | 18 +++ .../http/mcp/ConsentMcpJsonMapper.java | 68 ++++++++++ .../mcp/ConsentMcpJsonMapperSupplier.java | 24 ++++ .../consent/http/mcp/ConsentMcpManaged.java | 44 +++++++ .../http/mcp/ConsentMcpToolProvider.java | 124 ++++++++++++++++++ .../consent/http/mcp/McpAuthHelper.java | 52 ++++++++ .../consent/http/mcp/McpClaimsFilter.java | 59 +++++++++ .../consent/http/mcp/McpToolResults.java | 51 +++++++ ...contextprotocol.json.McpJsonMapperSupplier | 1 + ...ol.json.schema.JsonSchemaValidatorSupplier | 1 + 15 files changed, 581 insertions(+) create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidator.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/ConsentJsonSchemaValidatorSupplier.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapper.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpJsonMapperSupplier.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/McpClaimsFilter.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java create mode 100644 src/main/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier create mode 100644 src/main/resources/META-INF/services/io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier diff --git a/pom.xml b/pom.xml index 8d2f99d104..0225015656 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..2812ad02a5 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.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; 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; @@ -141,6 +145,20 @@ public void run(ConsentConfiguration config, Environment env) { System.setProperty("sun.net.http.allowRestrictedHeaders", "true"); env.jersey().register(JerseyGsonProvider.class); + // MCP Server-Sent Events endpoint + // McpClaimsFilter must be registered BEFORE the servlet so it runs first on /mcp/* requests. + // It reads OAUTH2_CLAIM_* headers set by Apache mod_oauth2 and populates ClaimsCache, + // mirroring what RequestHeaderCacheFilter does for Jersey requests on /api. + HttpServletSseServerTransportProvider mcpTransport = + injector.getInstance(HttpServletSseServerTransportProvider.class); + env.servlets() + .addFilter("mcp-claims-filter", new org.broadinstitute.consent.http.mcp.McpClaimsFilter()) + .addMappingForUrlPatterns( + EnumSet.of(DispatcherType.REQUEST), /* isMatchAfterFilter= */ false, "/mcp/*"); + env.servlets().addServlet("mcp-sse", mcpTransport).addMapping("/mcp/*"); + McpSyncServer mcpServer = injector.getInstance(McpSyncServer.class); + env.lifecycle().manage(new org.broadinstitute.consent.http.mcp.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 18e7844fac..8b5effb298 100644 --- a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java +++ b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java @@ -702,4 +702,53 @@ FeatureFlagDAO providesFeatureFlagDAO() { OntologyDAO providesOntologyDAO() { return ontologyDAO; } + + // ── MCP ────────────────────────────────────────────────────────────────────────────────────── + + @Provides + @com.google.inject.Singleton + io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider + providesMcpTransport() { + // SDK 0.14.x builder API (constructor is private): + // HttpServletSseServerTransportProvider.builder() + // .jsonMapper(McpJsonMapper) – ConsentMcpJsonMapper (networknt-free Jackson impl) + // .sseEndpoint(String) – GET /mcp → SSE stream + // .messageEndpoint(String) – POST /mcp/messages → client→server + // .keepAliveInterval(Duration) – SSE heartbeat interval + // .build() + // contextExtractor captures the Bearer token from each POST /mcp/messages request and stores it + // in McpTransportContext under the key "bearer". Tool handlers retrieve it via + // exchange.transportContext().get("bearer"), which works across Reactor scheduler threads where + // ThreadLocal propagation would fail. + return io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider.builder() + .jsonMapper( + new org.broadinstitute.consent.http.mcp.ConsentMcpJsonMapper( + new com.fasterxml.jackson.databind.ObjectMapper())) + .sseEndpoint("/mcp") + .messageEndpoint("/mcp/messages") + .keepAliveInterval(java.time.Duration.ofSeconds(30)) + .contextExtractor( + req -> { + String auth = req.getHeader("Authorization"); + String bearer = (auth != null && auth.startsWith("Bearer ")) ? auth.substring(7) : ""; + return io.modelcontextprotocol.common.McpTransportContext.create( + java.util.Map.of("bearer", bearer)); + }) + .build(); + } + + @Provides + @com.google.inject.Singleton + io.modelcontextprotocol.server.McpSyncServer providesMcpServer( + io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider transport, + org.broadinstitute.consent.http.mcp.ConsentMcpToolProvider toolProvider) { + return io.modelcontextprotocol.server.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..8824a7273c --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java @@ -0,0 +1,44 @@ +package org.broadinstitute.consent.http.mcp; + +import io.dropwizard.lifecycle.Managed; +import io.modelcontextprotocol.server.McpSyncServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dropwizard {@link Managed} wrapper for {@link McpSyncServer}. + * + *

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

In SDK 0.14.x, {@code McpSyncServer} has no explicit {@code start()} method — it begins + * serving as soon as the transport servlet receives connections. {@code closeGracefully()} is + * synchronous (returns void), so no reactive block is needed. + */ +public class ConsentMcpManaged implements Managed { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConsentMcpManaged.class); + + private final McpSyncServer server; + + public ConsentMcpManaged(McpSyncServer server) { + this.server = server; + } + + @Override + public void start() { + // McpSyncServer 0.14.x starts automatically when the transport receives the first connection. + 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..4ddf049101 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java @@ -0,0 +1,124 @@ +package org.broadinstitute.consent.http.mcp; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.List; +import java.util.Map; +import org.broadinstitute.consent.http.authentication.AuthorizationHelper; +import org.broadinstitute.consent.http.models.DatasetStudySummary; +import org.broadinstitute.consent.http.models.User; +import org.broadinstitute.consent.http.service.DatasetService; +import org.broadinstitute.consent.http.service.UserService; +import org.broadinstitute.consent.http.util.ConsentLogger; + +/** + * Assembles all MCP tool specifications for the Consent MCP server. + * + *

Each tool handler follows the same pattern: + * + *

    + *
  1. Call {@link McpAuthHelper#resolveUser} to authenticate the caller and load their roles. + *
  2. Delegate to the appropriate service method (same as the corresponding REST resource). + *
  3. Return the result via {@link McpToolResults}. + *
+ * + *

Error handling: unexpected exceptions are caught, logged, and returned as error results so + * that the MCP client receives a structured response rather than a raw 500. Authorization and + * not-found exceptions propagate their messages to the caller. + */ +@Singleton +public class ConsentMcpToolProvider implements ConsentLogger { + + // Input schema for dataset_search as a plain Map (JsonSchema in SDK 0.14.x is a record that + // accepts the raw schema via its inputSchemaObject component). + // query is optional; omitting it returns all datasets visible to the caller. + private static final Map DATASET_SEARCH_SCHEMA_MAP = + Map.of( + "type", + "object", + "properties", + Map.of( + "query", + Map.of( + "type", + "string", + "description", + "Case-insensitive text matched against dataset name and study name." + + " Omit to return all datasets visible to the caller."))); + + private final DatasetService datasetService; + private final AuthorizationHelper authorizationHelper; + private final UserService userService; + + @Inject + public ConsentMcpToolProvider( + DatasetService datasetService, + AuthorizationHelper authorizationHelper, + UserService userService) { + this.datasetService = datasetService; + this.authorizationHelper = authorizationHelper; + this.userService = userService; + } + + /** Returns all registered tool specifications. Add new tools here as phases are completed. */ + public List allTools() { + return List.of(datasetSearchToolSpec()); + } + + // ── dataset_search ────────────────────────────────────────────────────────────────────────── + + private McpServerFeatures.SyncToolSpecification datasetSearchToolSpec() { + // McpSchema.Tool is a record: (name, title, description, inputSchema, inputSchemaObject, + // annotations, extra). Pass null for the typed JsonSchema and supply the raw map instead. + McpSchema.Tool tool = + new McpSchema.Tool( + "dataset_search", + /* title= */ null, + "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.", + /* inputSchema= */ null, + DATASET_SEARCH_SCHEMA_MAP, + /* annotations= */ null, + /* extra= */ null); + return new McpServerFeatures.SyncToolSpecification(tool, this::handleDatasetSearch); + } + + /** + * Tool handler for {@code dataset_search}. + * + *

Resolves the calling user, fetches all dataset/study summaries they are permitted to see + * (via {@link DatasetService#findAllDatasetStudySummaries}), then optionally filters the results + * by a case-insensitive substring match on dataset name or study name. + */ + private McpSchema.CallToolResult handleDatasetSearch( + McpSyncServerExchange exchange, Map args) { + try { + User caller = McpAuthHelper.resolveUser(exchange, authorizationHelper, userService); + + List summaries = datasetService.findAllDatasetStudySummaries(caller); + + String query = args.containsKey("query") ? String.valueOf(args.get("query")).strip() : ""; + if (!query.isBlank()) { + String q = query.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 McpToolResults.of(summaries); + } catch (jakarta.ws.rs.NotAuthorizedException | jakarta.ws.rs.NotFoundException e) { + return McpToolResults.error(e.getMessage()); + } catch (Exception e) { + logException(e); + return McpToolResults.error("Unexpected error during dataset_search: " + e.getMessage()); + } + } +} 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..78fa8bce91 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java @@ -0,0 +1,52 @@ +package org.broadinstitute.consent.http.mcp; + +import io.modelcontextprotocol.server.McpSyncServerExchange; +import org.broadinstitute.consent.http.authentication.AuthorizationHelper; +import org.broadinstitute.consent.http.models.AuthUser; +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/messages. McpClaimsFilter intercepts each + * such request, populates ClaimsCache from the OAUTH2_CLAIM_* headers set by Apache mod_oauth2. The + * transport provider's contextExtractor (configured in ConsentModule) captures the raw Bearer token + * from the request and stores it in McpTransportContext under the key {@code "bearer"}. + * + *

Tool handlers call {@link #resolveUser(McpSyncServerExchange, AuthorizationHelper, + * UserService)} to obtain a fully populated {@link User} for the caller, including their roles. The + * bearer token is read from the exchange's transport context, which is propagated through the + * Reactor subscription context and therefore works correctly even when the SDK dispatches the tool + * handler on a different thread from the original HTTP request thread. + */ +public final class McpAuthHelper { + + private McpAuthHelper() {} + + /** + * Resolve the calling user for the current MCP tool invocation. + * + *

Reads the Bearer token from the exchange's {@link + * io.modelcontextprotocol.common.McpTransportContext} (stored by the transport's contextExtractor + * in ConsentModule), looks it up in ClaimsCache via {@link + * AuthorizationHelper#resolveAuthUser(String)}, then fetches the fully populated {@link User} + * (with roles) from the database via {@link UserService#findUserByEmail(String)}. + * + * @param exchange the current MCP exchange, which carries the transport context + * @param authorizationHelper to resolve AuthUser from the cached claims + * @param userService to load the full User record including roles + * @return the fully populated User for the caller + * @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 User resolveUser( + McpSyncServerExchange exchange, + AuthorizationHelper authorizationHelper, + UserService userService) { + Object bearerObj = exchange.transportContext().get("bearer"); + String bearer = bearerObj != null ? String.valueOf(bearerObj) : null; + AuthUser authUser = authorizationHelper.resolveAuthUser(bearer); + return userService.findUserByEmail(authUser.getEmail()); + } +} 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/McpToolResults.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java new file mode 100644 index 0000000000..9574d4bafe --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java @@ -0,0 +1,51 @@ +package org.broadinstitute.consent.http.mcp; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.List; +import org.broadinstitute.consent.http.util.gson.GsonUtil; + +/** + * Utility class for converting domain objects into {@link McpServerFeatures} CallToolResult values. + * + *

All serialisation uses {@link GsonUtil#buildGson()} to stay consistent with the REST layer's + * JSON output format (date handling, enum names, etc.). + */ +public final class McpToolResults { + + private McpToolResults() {} + + /** + * Wrap any serialisable object as a successful tool result containing a single JSON text node. + * + * @param value any object serialisable by GsonUtil + * @return a non-error CallToolResult whose single content item is the JSON string + */ + public static McpSchema.CallToolResult of(Object value) { + String json = GsonUtil.buildGson().toJson(value); + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(json)), /* isError= */ false); + } + + /** + * Wrap a plain text message as a successful tool result. + * + * @param text the text to return + * @return a non-error CallToolResult with a single text content item + */ + public static McpSchema.CallToolResult ofText(String text) { + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(text)), /* isError= */ false); + } + + /** + * Build an error result. The MCP client will receive isError=true and the message as text. + * + * @param message a human-readable error description + * @return an error CallToolResult + */ + public static McpSchema.CallToolResult error(String message) { + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(message)), /* isError= */ true); + } +} 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 From aa27acca3e0bf8a09a6e27c6533a83317fc764bf Mon Sep 17 00:00:00 2001 From: rushtong Date: Tue, 5 May 2026 12:26:19 -0400 Subject: [PATCH 2/8] poc wip: fixes --- .../http/mcp/ConsentMcpToolProvider.java | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java index 4ddf049101..48612c1bbb 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java @@ -32,14 +32,13 @@ @Singleton public class ConsentMcpToolProvider implements ConsentLogger { - // Input schema for dataset_search as a plain Map (JsonSchema in SDK 0.14.x is a record that - // accepts the raw schema via its inputSchemaObject component). - // query is optional; omitting it returns all datasets visible to the caller. - private static final Map DATASET_SEARCH_SCHEMA_MAP = - Map.of( - "type", + // Typed input schema for dataset_search. + // McpSchema.JsonSchema(type, properties, required, additionalProperties, defs, definitions). + // properties values are Map (each property is itself a plain map of JSON-Schema + // keywords). query is optional; omitting it returns all datasets visible to the caller. + private static final McpSchema.JsonSchema DATASET_SEARCH_INPUT_SCHEMA = + new McpSchema.JsonSchema( "object", - "properties", Map.of( "query", Map.of( @@ -47,7 +46,11 @@ public class ConsentMcpToolProvider implements ConsentLogger { "string", "description", "Case-insensitive text matched against dataset name and study name." - + " Omit to return all datasets visible to the caller."))); + + " Omit to return all datasets visible to the caller.")), + /* required= */ null, + /* additionalProperties= */ null, + /* defs= */ null, + /* definitions= */ null); private final DatasetService datasetService; private final AuthorizationHelper authorizationHelper; @@ -71,19 +74,19 @@ public List allTools() { // ── dataset_search ────────────────────────────────────────────────────────────────────────── private McpServerFeatures.SyncToolSpecification datasetSearchToolSpec() { - // McpSchema.Tool is a record: (name, title, description, inputSchema, inputSchemaObject, - // annotations, extra). Pass null for the typed JsonSchema and supply the raw map instead. + // McpSchema.Tool record in SDK 0.14.1: (name, title, description, inputSchema, outputSchema, + // annotations, meta). Use the builder so outputSchema stays null — passing a raw Map to the + // 5th constructor arg would populate outputSchema, which causes the SDK to require structured + // content in the result. McpSchema.Tool tool = - new McpSchema.Tool( - "dataset_search", - /* title= */ null, - "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.", - /* inputSchema= */ null, - DATASET_SEARCH_SCHEMA_MAP, - /* annotations= */ null, - /* extra= */ null); + McpSchema.Tool.builder() + .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.") + .inputSchema(DATASET_SEARCH_INPUT_SCHEMA) + .build(); return new McpServerFeatures.SyncToolSpecification(tool, this::handleDatasetSearch); } From 013b7977680d019e6de23ff8192671f8c4a33d0e Mon Sep 17 00:00:00 2001 From: rushtong Date: Wed, 6 May 2026 07:26:58 -0400 Subject: [PATCH 3/8] poc wip: update to non-deprecated mcp paths --- .../consent/http/ConsentApplication.java | 21 +++---- .../consent/http/ConsentModule.java | 55 ++++++++++--------- .../consent/http/mcp/ConsentMcpManaged.java | 15 +++-- .../http/mcp/ConsentMcpToolProvider.java | 15 ++--- .../consent/http/mcp/McpAuthHelper.java | 30 +++++----- .../consent/http/mcp/McpToolResults.java | 13 ++--- 6 files changed, 72 insertions(+), 77 deletions(-) diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java index 2812ad02a5..d8411e130d 100644 --- a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java +++ b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java @@ -14,8 +14,8 @@ import io.dropwizard.core.setup.Environment; import io.dropwizard.forms.MultiPartBundle; import io.dropwizard.jdbi3.bundles.JdbiExceptionsBundle; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport; import io.sentry.Sentry; import io.sentry.SentryLevel; import jakarta.servlet.DispatcherType; @@ -51,6 +51,7 @@ 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.McpClaimsFilter; import org.broadinstitute.consent.http.models.AuthUser; import org.broadinstitute.consent.http.models.DuosUser; import org.broadinstitute.consent.http.resources.DACAutomationRuleResource; @@ -145,18 +146,18 @@ public void run(ConsentConfiguration config, Environment env) { System.setProperty("sun.net.http.allowRestrictedHeaders", "true"); env.jersey().register(JerseyGsonProvider.class); - // MCP Server-Sent Events endpoint - // McpClaimsFilter must be registered BEFORE the servlet so it runs first on /mcp/* requests. + // 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. - HttpServletSseServerTransportProvider mcpTransport = - injector.getInstance(HttpServletSseServerTransportProvider.class); + HttpServletStatelessServerTransport mcpTransport = + injector.getInstance(HttpServletStatelessServerTransport.class); env.servlets() - .addFilter("mcp-claims-filter", new org.broadinstitute.consent.http.mcp.McpClaimsFilter()) + .addFilter("mcp-claims-filter", new McpClaimsFilter()) .addMappingForUrlPatterns( - EnumSet.of(DispatcherType.REQUEST), /* isMatchAfterFilter= */ false, "/mcp/*"); - env.servlets().addServlet("mcp-sse", mcpTransport).addMapping("/mcp/*"); - McpSyncServer mcpServer = injector.getInstance(McpSyncServer.class); + 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 org.broadinstitute.consent.http.mcp.ConsentMcpManaged(mcpServer)); // Metric Registry diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java index 8b5effb298..f80f57c55d 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; @@ -706,43 +713,37 @@ OntologyDAO providesOntologyDAO() { // ── MCP ────────────────────────────────────────────────────────────────────────────────────── @Provides - @com.google.inject.Singleton - io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider - providesMcpTransport() { - // SDK 0.14.x builder API (constructor is private): - // HttpServletSseServerTransportProvider.builder() - // .jsonMapper(McpJsonMapper) – ConsentMcpJsonMapper (networknt-free Jackson impl) - // .sseEndpoint(String) – GET /mcp → SSE stream - // .messageEndpoint(String) – POST /mcp/messages → client→server - // .keepAliveInterval(Duration) – SSE heartbeat interval - // .build() - // contextExtractor captures the Bearer token from each POST /mcp/messages request and stores it - // in McpTransportContext under the key "bearer". Tool handlers retrieve it via - // exchange.transportContext().get("bearer"), which works across Reactor scheduler threads where - // ThreadLocal propagation would fail. - return io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider.builder() - .jsonMapper( - new org.broadinstitute.consent.http.mcp.ConsentMcpJsonMapper( - new com.fasterxml.jackson.databind.ObjectMapper())) - .sseEndpoint("/mcp") - .messageEndpoint("/mcp/messages") - .keepAliveInterval(java.time.Duration.ofSeconds(30)) + @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 io.modelcontextprotocol.common.McpTransportContext.create( - java.util.Map.of("bearer", bearer)); + return McpTransportContext.create(java.util.Map.of("bearer", bearer)); }) .build(); } @Provides @com.google.inject.Singleton - io.modelcontextprotocol.server.McpSyncServer providesMcpServer( - io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider transport, - org.broadinstitute.consent.http.mcp.ConsentMcpToolProvider toolProvider) { - return io.modelcontextprotocol.server.McpServer.sync(transport) + 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() diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java index 8824a7273c..46f6a0d6d2 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpManaged.java @@ -1,33 +1,32 @@ package org.broadinstitute.consent.http.mcp; import io.dropwizard.lifecycle.Managed; -import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.McpStatelessSyncServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Dropwizard {@link Managed} wrapper for {@link McpSyncServer}. + * 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. * - *

In SDK 0.14.x, {@code McpSyncServer} has no explicit {@code start()} method — it begins - * serving as soon as the transport servlet receives connections. {@code closeGracefully()} is - * synchronous (returns void), so no reactive block is needed. + *

{@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 McpSyncServer server; + private final McpStatelessSyncServer server; - public ConsentMcpManaged(McpSyncServer server) { + public ConsentMcpManaged(McpStatelessSyncServer server) { this.server = server; } @Override public void start() { - // McpSyncServer 0.14.x starts automatically when the transport receives the first connection. + // McpStatelessSyncServer starts automatically when the transport servlet receives connections. LOGGER.info("Consent MCP server ready"); } diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java index 48612c1bbb..a5be72ae0f 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java @@ -2,8 +2,8 @@ import com.google.inject.Inject; import com.google.inject.Singleton; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; import java.util.Map; @@ -67,13 +67,13 @@ public ConsentMcpToolProvider( } /** Returns all registered tool specifications. Add new tools here as phases are completed. */ - public List allTools() { + public List allTools() { return List.of(datasetSearchToolSpec()); } // ── dataset_search ────────────────────────────────────────────────────────────────────────── - private McpServerFeatures.SyncToolSpecification datasetSearchToolSpec() { + private McpStatelessServerFeatures.SyncToolSpecification datasetSearchToolSpec() { // McpSchema.Tool record in SDK 0.14.1: (name, title, description, inputSchema, outputSchema, // annotations, meta). Use the builder so outputSchema stays null — passing a raw Map to the // 5th constructor arg would populate outputSchema, which causes the SDK to require structured @@ -87,7 +87,7 @@ private McpServerFeatures.SyncToolSpecification datasetSearchToolSpec() { + " Provide a query string to filter by name; omit it to list all accessible datasets.") .inputSchema(DATASET_SEARCH_INPUT_SCHEMA) .build(); - return new McpServerFeatures.SyncToolSpecification(tool, this::handleDatasetSearch); + return new McpStatelessServerFeatures.SyncToolSpecification(tool, this::handleDatasetSearch); } /** @@ -98,12 +98,13 @@ private McpServerFeatures.SyncToolSpecification datasetSearchToolSpec() { * by a case-insensitive substring match on dataset name or study name. */ private McpSchema.CallToolResult handleDatasetSearch( - McpSyncServerExchange exchange, Map args) { + McpTransportContext context, McpSchema.CallToolRequest request) { try { - User caller = McpAuthHelper.resolveUser(exchange, authorizationHelper, userService); + User caller = McpAuthHelper.resolveUser(context, authorizationHelper, userService); List summaries = datasetService.findAllDatasetStudySummaries(caller); + Map args = request.arguments() != null ? request.arguments() : Map.of(); String query = args.containsKey("query") ? String.valueOf(args.get("query")).strip() : ""; if (!query.isBlank()) { String q = query.toLowerCase(); diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java index 78fa8bce91..6d6b2f4dde 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java @@ -1,6 +1,6 @@ package org.broadinstitute.consent.http.mcp; -import io.modelcontextprotocol.server.McpSyncServerExchange; +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.User; @@ -9,16 +9,15 @@ /** * Authentication helper for MCP tool handlers. * - *

MCP tool calls arrive as HTTP POST requests to /mcp/messages. McpClaimsFilter intercepts each - * such request, populates ClaimsCache from the OAUTH2_CLAIM_* headers set by Apache mod_oauth2. The - * transport provider's contextExtractor (configured in ConsentModule) captures the raw Bearer token - * from the request and stores it in McpTransportContext under the key {@code "bearer"}. + *

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 call {@link #resolveUser(McpSyncServerExchange, AuthorizationHelper, - * UserService)} to obtain a fully populated {@link User} for the caller, including their roles. The - * bearer token is read from the exchange's transport context, which is propagated through the - * Reactor subscription context and therefore works correctly even when the SDK dispatches the tool - * handler on a different thread from the original HTTP request thread. + *

Tool handlers receive the {@link McpTransportContext} directly as the first parameter of the + * {@code BiFunction} handler, and call {@link #resolveUser(McpTransportContext, + * AuthorizationHelper, UserService)} to obtain a fully populated {@link User} for the caller + * including their roles. */ public final class McpAuthHelper { @@ -27,13 +26,12 @@ private McpAuthHelper() {} /** * Resolve the calling user for the current MCP tool invocation. * - *

Reads the Bearer token from the exchange's {@link - * io.modelcontextprotocol.common.McpTransportContext} (stored by the transport's contextExtractor - * in ConsentModule), looks it up in ClaimsCache via {@link + *

Reads the Bearer token from the supplied {@link McpTransportContext} (stored by the + * transport's contextExtractor in ConsentModule), looks it up in ClaimsCache via {@link * AuthorizationHelper#resolveAuthUser(String)}, then fetches the fully populated {@link User} * (with roles) from the database via {@link UserService#findUserByEmail(String)}. * - * @param exchange the current MCP exchange, which carries the transport context + * @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 the fully populated User for the caller @@ -41,10 +39,10 @@ private McpAuthHelper() {} * @throws jakarta.ws.rs.NotFoundException if the email from the token is not registered in DUOS */ public static User resolveUser( - McpSyncServerExchange exchange, + McpTransportContext context, AuthorizationHelper authorizationHelper, UserService userService) { - Object bearerObj = exchange.transportContext().get("bearer"); + Object bearerObj = context.get("bearer"); String bearer = bearerObj != null ? String.valueOf(bearerObj) : null; AuthUser authUser = authorizationHelper.resolveAuthUser(bearer); return userService.findUserByEmail(authUser.getEmail()); diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java index 9574d4bafe..3a5cb4cadc 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java @@ -1,12 +1,10 @@ package org.broadinstitute.consent.http.mcp; -import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; -import java.util.List; import org.broadinstitute.consent.http.util.gson.GsonUtil; /** - * Utility class for converting domain objects into {@link McpServerFeatures} CallToolResult values. + * 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.). @@ -23,8 +21,7 @@ private McpToolResults() {} */ public static McpSchema.CallToolResult of(Object value) { String json = GsonUtil.buildGson().toJson(value); - return new McpSchema.CallToolResult( - List.of(new McpSchema.TextContent(json)), /* isError= */ false); + return McpSchema.CallToolResult.builder().addTextContent(json).isError(false).build(); } /** @@ -34,8 +31,7 @@ public static McpSchema.CallToolResult of(Object value) { * @return a non-error CallToolResult with a single text content item */ public static McpSchema.CallToolResult ofText(String text) { - return new McpSchema.CallToolResult( - List.of(new McpSchema.TextContent(text)), /* isError= */ false); + return McpSchema.CallToolResult.builder().addTextContent(text).isError(false).build(); } /** @@ -45,7 +41,6 @@ public static McpSchema.CallToolResult ofText(String text) { * @return an error CallToolResult */ public static McpSchema.CallToolResult error(String message) { - return new McpSchema.CallToolResult( - List.of(new McpSchema.TextContent(message)), /* isError= */ true); + return McpSchema.CallToolResult.builder().addTextContent(message).isError(true).build(); } } From 9682623005eb6d464caaba2a9b1ddd6081094a66 Mon Sep 17 00:00:00 2001 From: rushtong Date: Wed, 6 May 2026 07:28:30 -0400 Subject: [PATCH 4/8] poc wip: clean up import --- .../org/broadinstitute/consent/http/ConsentApplication.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java index d8411e130d..cc526e98ca 100644 --- a/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java +++ b/src/main/java/org/broadinstitute/consent/http/ConsentApplication.java @@ -51,6 +51,7 @@ 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; @@ -158,7 +159,7 @@ public void run(ConsentConfiguration config, Environment env) { 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 org.broadinstitute.consent.http.mcp.ConsentMcpManaged(mcpServer)); + env.lifecycle().manage(new ConsentMcpManaged(mcpServer)); // Metric Registry MetricRegistry metricRegistry = new MetricRegistry(); From ca8931b0f225321898e43f3cdcfdfe3073078a1b Mon Sep 17 00:00:00 2001 From: rushtong Date: Wed, 6 May 2026 08:12:59 -0400 Subject: [PATCH 5/8] poc wip: clean up formats --- .../http/mcp/ConsentMcpToolProvider.java | 13 +++++++-- .../consent/http/mcp/McpToolResults.java | 29 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java index a5be72ae0f..dde4a4d62d 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java @@ -32,6 +32,7 @@ @Singleton public class ConsentMcpToolProvider implements ConsentLogger { + private static final String QUERY = "query"; // Typed input schema for dataset_search. // McpSchema.JsonSchema(type, properties, required, additionalProperties, defs, definitions). // properties values are Map (each property is itself a plain map of JSON-Schema @@ -40,7 +41,7 @@ public class ConsentMcpToolProvider implements ConsentLogger { new McpSchema.JsonSchema( "object", Map.of( - "query", + QUERY, Map.of( "type", "string", @@ -52,6 +53,13 @@ public class ConsentMcpToolProvider implements ConsentLogger { /* defs= */ null, /* definitions= */ null); + // Output schema for dataset_search: an array of dataset/study summary objects. + // outputSchema on Tool.builder() takes Map, not McpSchema.JsonSchema + // (unlike inputSchema which takes McpSchema.JsonSchema). + // Declaring an outputSchema causes the SDK to expect structuredContent in the result, + // which McpToolResults.of() now provides. + private static final Map DATASET_SEARCH_OUTPUT_SCHEMA = Map.of("type", "array"); + private final DatasetService datasetService; private final AuthorizationHelper authorizationHelper; private final UserService userService; @@ -86,6 +94,7 @@ private McpStatelessServerFeatures.SyncToolSpecification datasetSearchToolSpec() + " 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.") .inputSchema(DATASET_SEARCH_INPUT_SCHEMA) + .outputSchema(DATASET_SEARCH_OUTPUT_SCHEMA) .build(); return new McpStatelessServerFeatures.SyncToolSpecification(tool, this::handleDatasetSearch); } @@ -105,7 +114,7 @@ private McpSchema.CallToolResult handleDatasetSearch( List summaries = datasetService.findAllDatasetStudySummaries(caller); Map args = request.arguments() != null ? request.arguments() : Map.of(); - String query = args.containsKey("query") ? String.valueOf(args.get("query")).strip() : ""; + String query = args.containsKey(QUERY) ? String.valueOf(args.get(QUERY)).strip() : ""; if (!query.isBlank()) { String q = query.toLowerCase(); summaries = diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java index 3a5cb4cadc..acac5dcb48 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java @@ -1,27 +1,48 @@ 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 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.). + * 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 containing a single JSON text node. + * 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 whose single content item is the JSON string + * @return a non-error CallToolResult with structured content */ public static McpSchema.CallToolResult of(Object value) { String json = GsonUtil.buildGson().toJson(value); - return McpSchema.CallToolResult.builder().addTextContent(json).isError(false).build(); + try { + Object parsed = OBJECT_MAPPER.readValue(json, OBJECT_TYPE); + return McpSchema.CallToolResult.builder().structuredContent(parsed).isError(false).build(); + } catch (Exception e) { + LOGGER.warn("Failed to parse tool result as structured content; falling back to text", e); + return McpSchema.CallToolResult.builder().addTextContent(json).isError(false).build(); + } } /** From ce16ad2e96d328a13f462930dd40f7e9d9211320 Mon Sep 17 00:00:00 2001 From: rushtong Date: Wed, 6 May 2026 15:23:23 -0400 Subject: [PATCH 6/8] poc wip: use an annotation-based approach to registering MCP tools --- .../http/mcp/ConsentMcpToolProvider.java | 131 ++------ .../consent/http/mcp/McpAuthHelper.java | 25 +- .../consent/http/mcp/McpTool.java | 60 ++++ .../consent/http/mcp/McpToolParam.java | 29 ++ .../consent/http/mcp/McpToolResults.java | 31 +- .../consent/http/mcp/McpToolScanner.java | 305 ++++++++++++++++++ .../http/resources/DatasetResource.java | 48 ++- .../resources/assets/paths/datasetV3.yaml | 11 + .../http/resources/DatasetResourceTest.java | 2 +- 9 files changed, 513 insertions(+), 129 deletions(-) create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/McpTool.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/McpToolParam.java create mode 100644 src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java index dde4a4d62d..6ac3a57ab5 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/ConsentMcpToolProvider.java @@ -2,136 +2,49 @@ import com.google.inject.Inject; import com.google.inject.Singleton; -import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpStatelessServerFeatures; -import io.modelcontextprotocol.spec.McpSchema; import java.util.List; -import java.util.Map; import org.broadinstitute.consent.http.authentication.AuthorizationHelper; -import org.broadinstitute.consent.http.models.DatasetStudySummary; -import org.broadinstitute.consent.http.models.User; -import org.broadinstitute.consent.http.service.DatasetService; +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. + * Assembles all MCP tool specifications for the Consent MCP server by delegating to {@link + * McpToolScanner}. * - *

Each tool handler follows the same pattern: + *

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()}. * - *

    - *
  1. Call {@link McpAuthHelper#resolveUser} to authenticate the caller and load their roles. - *
  2. Delegate to the appropriate service method (same as the corresponding REST resource). - *
  3. Return the result via {@link McpToolResults}. - *
- * - *

Error handling: unexpected exceptions are caught, logged, and returned as error results so - * that the MCP client receives a structured response rather than a raw 500. Authorization and - * not-found exceptions propagate their messages to the caller. + *

{@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 static final String QUERY = "query"; - // Typed input schema for dataset_search. - // McpSchema.JsonSchema(type, properties, required, additionalProperties, defs, definitions). - // properties values are Map (each property is itself a plain map of JSON-Schema - // keywords). query is optional; omitting it returns all datasets visible to the caller. - private static final McpSchema.JsonSchema DATASET_SEARCH_INPUT_SCHEMA = - new McpSchema.JsonSchema( - "object", - Map.of( - QUERY, - Map.of( - "type", - "string", - "description", - "Case-insensitive text matched against dataset name and study name." - + " Omit to return all datasets visible to the caller.")), - /* required= */ null, - /* additionalProperties= */ null, - /* defs= */ null, - /* definitions= */ null); - - // Output schema for dataset_search: an array of dataset/study summary objects. - // outputSchema on Tool.builder() takes Map, not McpSchema.JsonSchema - // (unlike inputSchema which takes McpSchema.JsonSchema). - // Declaring an outputSchema causes the SDK to expect structuredContent in the result, - // which McpToolResults.of() now provides. - private static final Map DATASET_SEARCH_OUTPUT_SCHEMA = Map.of("type", "array"); - - private final DatasetService datasetService; - private final AuthorizationHelper authorizationHelper; - private final UserService userService; + private final McpToolScanner scanner; + private final DatasetResource datasetResource; @Inject public ConsentMcpToolProvider( - DatasetService datasetService, AuthorizationHelper authorizationHelper, - UserService userService) { - this.datasetService = datasetService; - this.authorizationHelper = authorizationHelper; - this.userService = userService; - } - - /** Returns all registered tool specifications. Add new tools here as phases are completed. */ - public List allTools() { - return List.of(datasetSearchToolSpec()); - } - - // ── dataset_search ────────────────────────────────────────────────────────────────────────── - - private McpStatelessServerFeatures.SyncToolSpecification datasetSearchToolSpec() { - // McpSchema.Tool record in SDK 0.14.1: (name, title, description, inputSchema, outputSchema, - // annotations, meta). Use the builder so outputSchema stays null — passing a raw Map to the - // 5th constructor arg would populate outputSchema, which causes the SDK to require structured - // content in the result. - McpSchema.Tool tool = - McpSchema.Tool.builder() - .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.") - .inputSchema(DATASET_SEARCH_INPUT_SCHEMA) - .outputSchema(DATASET_SEARCH_OUTPUT_SCHEMA) - .build(); - return new McpStatelessServerFeatures.SyncToolSpecification(tool, this::handleDatasetSearch); + UserService userService, + DatasetResource datasetResource) { + this.scanner = new McpToolScanner(authorizationHelper, userService); + this.datasetResource = datasetResource; } /** - * Tool handler for {@code dataset_search}. + * Returns all registered MCP tool specifications. * - *

Resolves the calling user, fetches all dataset/study summaries they are permitted to see - * (via {@link DatasetService#findAllDatasetStudySummaries}), then optionally filters the results - * by a case-insensitive substring match on dataset name or study name. + *

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. */ - private McpSchema.CallToolResult handleDatasetSearch( - McpTransportContext context, McpSchema.CallToolRequest request) { - try { - User caller = McpAuthHelper.resolveUser(context, authorizationHelper, userService); - - List summaries = datasetService.findAllDatasetStudySummaries(caller); - - Map args = request.arguments() != null ? request.arguments() : Map.of(); - String query = args.containsKey(QUERY) ? String.valueOf(args.get(QUERY)).strip() : ""; - if (!query.isBlank()) { - String q = query.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 McpToolResults.of(summaries); - } catch (jakarta.ws.rs.NotAuthorizedException | jakarta.ws.rs.NotFoundException e) { - return McpToolResults.error(e.getMessage()); - } catch (Exception e) { - logException(e); - return McpToolResults.error("Unexpected error during dataset_search: " + e.getMessage()); - } + 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 index 6d6b2f4dde..99d4b7506b 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java @@ -3,6 +3,7 @@ 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; @@ -14,37 +15,37 @@ * 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 receive the {@link McpTransportContext} directly as the first parameter of the - * {@code BiFunction} handler, and call {@link #resolveUser(McpTransportContext, - * AuthorizationHelper, UserService)} to obtain a fully populated {@link User} for the caller - * including their roles. + *

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 for the current MCP tool invocation. + * 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} (stored by the - * transport's contextExtractor in ConsentModule), looks it up in ClaimsCache via {@link - * AuthorizationHelper#resolveAuthUser(String)}, then fetches the fully populated {@link User} - * (with roles) from the database via {@link UserService#findUserByEmail(String)}. + *

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 the fully populated User for the caller + * @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 User resolveUser( + 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); - return userService.findUserByEmail(authUser.getEmail()); + User user = userService.findUserByEmail(authUser.getEmail()); + return new DuosUser(authUser, user); } } 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 index acac5dcb48..75c3c98424 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolResults.java @@ -3,6 +3,7 @@ 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; @@ -40,28 +41,46 @@ public static McpSchema.CallToolResult of(Object value) { Object parsed = OBJECT_MAPPER.readValue(json, OBJECT_TYPE); return McpSchema.CallToolResult.builder().structuredContent(parsed).isError(false).build(); } catch (Exception e) { - LOGGER.warn("Failed to parse tool result as structured content; falling back to text", e); - return McpSchema.CallToolResult.builder().addTextContent(json).isError(false).build(); + // 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 a single text content item + * @return a non-error CallToolResult with structured content */ public static McpSchema.CallToolResult ofText(String text) { - return McpSchema.CallToolResult.builder().addTextContent(text).isError(false).build(); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("text", text)) + .isError(false) + .build(); } /** - * Build an error result. The MCP client will receive isError=true and the message as text. + * 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().addTextContent(message).isError(true).build(); + 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..9d0e8ba7ee --- /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 { + + 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/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()); } } From 45faf516e3b93cc9399054b3c95dc47954a54084 Mon Sep 17 00:00:00 2001 From: rushtong Date: Wed, 6 May 2026 15:26:42 -0400 Subject: [PATCH 7/8] poc wip: spotless --- .../org/broadinstitute/consent/http/mcp/McpAuthHelper.java | 6 +++--- .../org/broadinstitute/consent/http/mcp/McpToolScanner.java | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java index 99d4b7506b..aafb24f09c 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpAuthHelper.java @@ -15,9 +15,9 @@ * 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}. + *

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 { diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java index 9d0e8ba7ee..a9cc23ca64 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java @@ -131,8 +131,7 @@ private McpStatelessServerFeatures.SyncToolSpecification buildSpec( // 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())); + properties.put(extra.name(), Map.of("type", extra.type(), DESCRIPTION, extra.description())); if (extra.required()) { required.add(extra.name()); } From 8655464c504cda045cbff52e60cdb38503ccd24f Mon Sep 17 00:00:00 2001 From: rushtong Date: Wed, 6 May 2026 15:29:03 -0400 Subject: [PATCH 8/8] poc wip: semgrep --- .../java/org/broadinstitute/consent/http/mcp/McpToolScanner.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java index a9cc23ca64..725e616090 100644 --- a/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java +++ b/src/main/java/org/broadinstitute/consent/http/mcp/McpToolScanner.java @@ -49,6 +49,7 @@ */ public final class McpToolScanner implements ConsentLogger { + // nosemgrep private static final String DESCRIPTION = "description"; private final AuthorizationHelper authorizationHelper; private final UserService userService;