diff --git a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java index 48ec56d3..ae616dc3 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java @@ -41,6 +41,7 @@ import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.NamedList; import org.apache.solr.mcp.server.config.SolrConfigurationProperties; +import org.jspecify.annotations.Nullable; import org.springaicommunity.mcp.annotation.McpComplete; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; @@ -556,7 +557,7 @@ public QueryStats buildQueryStats(QueryResponse response) { * @see #extractCacheStats(NamedList) * @see #isCacheStatsEmpty(CacheStats) */ - public CacheStats getCacheMetrics(String collection) { + public @Nullable CacheStats getCacheMetrics(String collection) { String actualCollection = extractCollectionName(collection); if (!validateCollectionExists(actualCollection)) { @@ -570,7 +571,7 @@ public CacheStats getCacheMetrics(String collection) { * Internal cache metrics fetch that assumes the collection has already been * validated and the name has been extracted from any shard identifier. */ - private CacheStats fetchCacheMetrics(String collection) { + private @Nullable CacheStats fetchCacheMetrics(String collection) { try { NamedList coreMetrics = fetchMetrics(collection, CACHE_METRIC_PREFIX); if (coreMetrics == null) { @@ -596,7 +597,7 @@ private CacheStats fetchCacheMetrics(String collection) { * the cache statistics to evaluate * @return true if the stats are null or all cache types are null */ - private boolean isCacheStatsEmpty(CacheStats stats) { + private boolean isCacheStatsEmpty(@Nullable CacheStats stats) { return stats == null || (stats.queryResultCache() == null && stats.documentCache() == null && stats.filterCache() == null); } @@ -615,7 +616,7 @@ private CacheStats extractCacheStats(NamedList coreMetrics) { } @SuppressWarnings("unchecked") - private CacheInfo extractSingleCacheInfo(NamedList coreMetrics, String key) { + private @Nullable CacheInfo extractSingleCacheInfo(NamedList coreMetrics, String key) { NamedList cache = (NamedList) coreMetrics.get(key); if (cache == null) { return null; @@ -668,7 +669,7 @@ private CacheInfo extractSingleCacheInfo(NamedList coreMetrics, String k * @see #fetchFlatHandlerInfo(String, String, String) * @see #isHandlerStatsEmpty(HandlerStats) */ - public HandlerStats getHandlerMetrics(String collection) { + public @Nullable HandlerStats getHandlerMetrics(String collection) { String actualCollection = extractCollectionName(collection); if (!validateCollectionExists(actualCollection)) { @@ -682,7 +683,7 @@ public HandlerStats getHandlerMetrics(String collection) { * Internal handler metrics fetch that assumes the collection has already been * validated and the name has been extracted from any shard identifier. */ - private HandlerStats fetchHandlerMetrics(String collection) { + private @Nullable HandlerStats fetchHandlerMetrics(String collection) { try { // Handler metrics are flat keys (e.g. QUERY./select.requests) so we // fetch each handler prefix separately and reconstruct HandlerInfo @@ -710,7 +711,7 @@ private HandlerStats fetchHandlerMetrics(String collection) { * the handler statistics to evaluate * @return true if the stats are null or all handler types are null */ - private boolean isHandlerStatsEmpty(HandlerStats stats) { + private boolean isHandlerStatsEmpty(@Nullable HandlerStats stats) { return stats == null || (stats.selectHandler() == null && stats.updateHandler() == null); } @@ -724,7 +725,8 @@ private boolean isHandlerStatsEmpty(HandlerStats stats) { * @return the core-level metrics NamedList, or null if unavailable */ @SuppressWarnings("unchecked") - private NamedList fetchMetrics(String collection, String prefix) throws SolrServerException, IOException { + private @Nullable NamedList fetchMetrics(String collection, String prefix) + throws SolrServerException, IOException { ModifiableSolrParams params = new ModifiableSolrParams(); params.set(GROUP_PARAM, CORE_GROUP); params.set(PREFIX_PARAM, prefix); @@ -769,7 +771,7 @@ private NamedList fetchMetrics(String collection, String prefix) throws * {@code QUERY./select.}) * @return HandlerInfo with stats, or null if unavailable */ - private HandlerInfo fetchFlatHandlerInfo(String collection, String metricPrefix, String keyPrefix) + private @Nullable HandlerInfo fetchFlatHandlerInfo(String collection, String metricPrefix, String keyPrefix) throws SolrServerException, IOException { NamedList coreMetrics = fetchMetrics(collection, metricPrefix); if (coreMetrics == null) { @@ -789,7 +791,7 @@ private HandlerInfo fetchFlatHandlerInfo(String collection, String metricPrefix, * @return HandlerInfo reconstructed from flat keys, or null if no requests key * found */ - private HandlerInfo extractFlatHandlerInfo(NamedList coreMetrics, String keyPrefix) { + private @Nullable HandlerInfo extractFlatHandlerInfo(NamedList coreMetrics, String keyPrefix) { Long requests = getLong(coreMetrics, keyPrefix + REQUESTS_FIELD); if (requests == null) { return null; @@ -836,7 +838,7 @@ private HandlerInfo extractFlatHandlerInfo(NamedList coreMetrics, String * @return the extracted collection name, or the original string if no shard * pattern found */ - String extractCollectionName(String collectionOrShard) { + @Nullable String extractCollectionName(@Nullable String collectionOrShard) { if (collectionOrShard == null || collectionOrShard.isEmpty()) { return collectionOrShard; } @@ -1011,9 +1013,9 @@ public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection + "configSet defaults to _default, numShards and replicationFactor default to 1.") public CollectionCreationResult createCollection( @McpToolParam(description = "Name of the collection to create") String name, - @McpToolParam(description = "Configset name. Defaults to _default.", required = false) String configSet, - @McpToolParam(description = "Number of shards (SolrCloud only). Defaults to 1.", required = false) Integer numShards, - @McpToolParam(description = "Replication factor (SolrCloud only). Defaults to 1.", required = false) Integer replicationFactor) + @McpToolParam(description = "Configset name. Defaults to _default.", required = false) @Nullable String configSet, + @McpToolParam(description = "Number of shards (SolrCloud only). Defaults to 1.", required = false) @Nullable Integer numShards, + @McpToolParam(description = "Replication factor (SolrCloud only). Defaults to 1.", required = false) @Nullable Integer replicationFactor) throws SolrServerException, IOException { if (name == null || name.isBlank()) { diff --git a/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java b/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java index 2df9309a..8e0c3154 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java @@ -17,6 +17,7 @@ package org.apache.solr.mcp.server.collection; import org.apache.solr.common.util.NamedList; +import org.jspecify.annotations.Nullable; /** * Utility class providing type-safe helper methods for extracting values from @@ -113,7 +114,7 @@ private CollectionUtils() { * @see Number#longValue() * @see Long#parseLong(String) */ - public static Long getLong(NamedList response, String key) { + public static @Nullable Long getLong(NamedList response, String key) { Object value = response.get(key); if (value == null) return null; @@ -180,7 +181,7 @@ public static Long getLong(NamedList response, String key) { * is null * @see Number#floatValue() */ - public static Float getFloat(NamedList stats, String key) { + public static @Nullable Float getFloat(NamedList stats, String key) { Object value = stats.get(key); if (value == null) return null; @@ -260,7 +261,7 @@ public static Float getFloat(NamedList stats, String key) { * @see Integer#parseInt(String) * @see #getLong(NamedList, String) */ - public static Integer getInteger(NamedList response, String key) { + public static @Nullable Integer getInteger(NamedList response, String key) { Object value = response.get(key); if (value == null) return null; diff --git a/src/main/java/org/apache/solr/mcp/server/collection/Dtos.java b/src/main/java/org/apache/solr/mcp/server/collection/Dtos.java index 13a898ef..89a756ca 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/Dtos.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/Dtos.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import java.util.Date; +import org.jspecify.annotations.Nullable; /** * Data Transfer Objects (DTOs) for the Apache Solr MCP Server. @@ -95,13 +96,13 @@ record SolrMetrics( * Cache utilization statistics for query result, document, and filter caches * (may be null) */ - CacheStats cacheStats, + @Nullable CacheStats cacheStats, /** * Request handler performance metrics for select and update operations (may be * null) */ - HandlerStats handlerStats, + @Nullable HandlerStats handlerStats, /** Timestamp when these metrics were collected, formatted as ISO 8601 */ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { @@ -145,7 +146,7 @@ record IndexStats( * Number of Lucene segments in the index (lower numbers generally indicate * better performance) */ - Integer segmentCount) { + @Nullable Integer segmentCount) { } /** @@ -231,8 +232,11 @@ record QueryStats( /** Starting position for paginated results (0-based offset) */ Long start, - /** Highest relevance score among the returned documents */ - Float maxScore) { + /** + * Highest relevance score among the returned documents (null when scoring + * disabled) + */ + @Nullable Float maxScore) { } /** @@ -265,14 +269,16 @@ record QueryStats( @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record CacheStats( - /** Performance metrics for the query result cache */ - CacheInfo queryResultCache, + /** + * Performance metrics for the query result cache (null if cache not configured) + */ + @Nullable CacheInfo queryResultCache, - /** Performance metrics for the document cache */ - CacheInfo documentCache, + /** Performance metrics for the document cache (null if cache not configured) */ + @Nullable CacheInfo documentCache, - /** Performance metrics for the filter cache */ - CacheInfo filterCache) { + /** Performance metrics for the filter cache (null if cache not configured) */ + @Nullable CacheInfo filterCache) { } /** @@ -359,11 +365,17 @@ record CacheInfo( @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) record HandlerStats( - /** Performance metrics for the search/select request handler */ - HandlerInfo selectHandler, + /** + * Performance metrics for the search/select request handler (null if handler + * unavailable) + */ + @Nullable HandlerInfo selectHandler, - /** Performance metrics for the document update request handler */ - HandlerInfo updateHandler) { + /** + * Performance metrics for the document update request handler (null if handler + * unavailable) + */ + @Nullable HandlerInfo updateHandler) { } /** @@ -397,20 +409,27 @@ record HandlerInfo( /** Total number of requests processed by this handler */ Long requests, - /** Number of requests that resulted in errors */ - Long errors, + /** Number of requests that resulted in errors (null if metric unavailable) */ + @Nullable Long errors, - /** Number of requests that exceeded timeout limits */ - Long timeouts, + /** + * Number of requests that exceeded timeout limits (null if metric unavailable) + */ + @Nullable Long timeouts, - /** Cumulative time spent processing all requests (milliseconds) */ - Long totalTime, + /** + * Cumulative time spent processing all requests (milliseconds, null if metric + * unavailable) + */ + @Nullable Long totalTime, - /** Average time per request in milliseconds */ - Float avgTimePerRequest, + /** + * Average time per request in milliseconds (null if unavailable or no requests) + */ + @Nullable Float avgTimePerRequest, - /** Average throughput in requests per second */ - Float avgRequestsPerSecond) { + /** Average throughput in requests per second (null if unavailable) */ + @Nullable Float avgRequestsPerSecond) { } /** @@ -455,13 +474,18 @@ record SolrHealthStatus( boolean isHealthy, /** Detailed error message when isHealthy is false, null when healthy */ - String errorMessage, + @Nullable String errorMessage, - /** Response time in milliseconds for the health check ping request */ - Long responseTime, + /** + * Response time in milliseconds for the health check ping request (null on + * error) + */ + @Nullable Long responseTime, - /** Total number of documents currently indexed in the collection */ - Long totalDocuments, + /** + * Total number of documents currently indexed in the collection (null on error) + */ + @Nullable Long totalDocuments, /** Timestamp when this health check was performed, formatted as ISO 8601 */ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date lastChecked, @@ -469,11 +493,13 @@ record SolrHealthStatus( /** Name of the collection that was checked */ String collection, - /** Version of Solr server (when available) */ - String solrVersion, + /** Version of Solr server (null when unavailable) */ + @Nullable String solrVersion, - /** Additional status information or state description */ - String status) { + /** + * Additional status information or state description (null when unavailable) + */ + @Nullable String status) { } /** diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index b6047837..7e7f9836 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -193,8 +193,10 @@ SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser // JSON wire format for responses; XML wire format for update requests. // The default JavaBin request writer uses a binary codec that requires // additional reflection metadata in GraalVM native images. + // Force HTTP/1.1: the JDK HttpClient's HTTP/2 transport intermittently + // closes reused connections with an EOFException against Solr/Jetty. return new HttpJdkSolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).withResponseParser(jsonResponseParser) - .withRequestWriter(new XMLRequestWriter()).build(); + .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).useHttp1_1(true) + .withResponseParser(jsonResponseParser).withRequestWriter(new XMLRequestWriter()).build(); } } diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java b/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java index 3e8d914f..029d8ff1 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchResponse.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; /** * Immutable record representing a structured search response from Apache Solr @@ -134,6 +135,6 @@ * @see org.apache.solr.client.solrj.response.QueryResponse * @see org.apache.solr.common.SolrDocumentList */ -public record SearchResponse(long numFound, long start, Float maxScore, List> documents, +public record SearchResponse(long numFound, long start, @Nullable Float maxScore, List> documents, Map> facets) { } diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java index c29ccb6a..8ba6cc09 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java @@ -30,6 +30,7 @@ import org.apache.solr.common.SolrDocument; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.params.FacetParams; +import org.jspecify.annotations.Nullable; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; @@ -242,12 +243,12 @@ private static Map> getFacets(QueryResponse queryRespo } """) public SearchResponse search(@McpToolParam(description = "Solr collection to query") String collection, - @McpToolParam(description = "Solr q parameter. If none specified defaults to \"*:*\"", required = false) String query, - @McpToolParam(description = "Solr fq parameter", required = false) List filterQueries, - @McpToolParam(description = "Solr facet fields", required = false) List facetFields, - @McpToolParam(description = "Solr sort parameter", required = false) List> sortClauses, - @McpToolParam(description = "Starting offset for pagination", required = false) Integer start, - @McpToolParam(description = "Number of rows to return", required = false) Integer rows) + @McpToolParam(description = "Solr q parameter. If none specified defaults to \"*:*\"", required = false) @Nullable String query, + @McpToolParam(description = "Solr fq parameter", required = false) @Nullable List filterQueries, + @McpToolParam(description = "Solr facet fields", required = false) @Nullable List facetFields, + @McpToolParam(description = "Solr sort parameter", required = false) @Nullable List> sortClauses, + @McpToolParam(description = "Starting offset for pagination", required = false) @Nullable Integer start, + @McpToolParam(description = "Number of rows to return", required = false) @Nullable Integer rows) throws SolrServerException, IOException { // query