From 487f09087d1c5a4668ff2b8a880f03ad02b1b5bb Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sat, 28 Feb 2026 23:38:36 +0000 Subject: [PATCH 01/13] feat(config): switch Solr wire format from JavaBin to JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the default BinaryResponseParser (wt=javabin) with a custom JsonResponseParser (wt=json) for future-proofing and improved debuggability. The JsonResponseParser converts Solr's JSON response envelope into the NamedList tree that SolrJ typed response classes expect: - JSON objects → SimpleOrderedMap (extends NamedList, implements Map, satisfying both QueryResponse's NamedList casts and SchemaResponse's Map cast) - JSON objects with numFound+docs → SolrDocumentList - Flat alternating arrays [String, non-String, ...] → SimpleOrderedMap (Solr's json.nl=flat encoding for facet counts) - All other arrays → List - Decimal numbers → Float (matching JavaBin's float type, required by SchemaResponse's (Float) version cast) - Small integers → Integer, large integers → Long Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .../mcp/server/config/JsonResponseParser.java | 207 ++++++++++++++++++ .../solr/mcp/server/config/SolrConfig.java | 5 +- 2 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java diff --git a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java new file mode 100644 index 0000000..97f5053 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; +import org.apache.solr.client.solrj.ResponseParser; +import org.apache.solr.common.SolrDocument; +import org.apache.solr.common.SolrDocumentList; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; + +/** + * SolrJ {@link ResponseParser} that requests JSON wire format ({@code wt=json}) + * and converts the Solr JSON response into the {@link NamedList} object tree + * that SolrJ's typed response classes + * ({@link org.apache.solr.client.solrj.response.QueryResponse}, + * {@link org.apache.solr.client.solrj.response.LukeResponse}, etc.) expect + * internally. + * + *

+ * This allows the application to keep all existing SolrJ response handling + * unchanged while moving off the JavaBin ({@code wt=javabin}) wire format. + * + *

+ * Structural conversions: + *

    + *
  • JSON objects → {@link NamedList} (preserving key order)
  • + *
  • JSON objects containing {@code numFound} + {@code docs} → + * {@link SolrDocumentList}
  • + *
  • JSON arrays with alternating {@code [String, non-String, ...]} pairs → + * {@link NamedList} (Solr's {@code json.nl=flat} facet encoding)
  • + *
  • All other JSON arrays → {@link List}
  • + *
  • JSON integers → {@link Integer} or {@link Long} (by value size)
  • + *
  • JSON decimals → {@link Double}
  • + *
  • JSON booleans → {@link Boolean}
  • + *
  • JSON strings → {@link String}
  • + *
+ * + *

+ * Flat NamedList detection: Solr serializes facet counts as + * flat arrays using {@code json.nl=flat} (the default). An array is treated as + * a flat NamedList when every even-indexed element is a {@link String} and + * every odd-indexed element is a non-{@link String} value. This reliably + * distinguishes {@code ["term", 5, "term2", 3]} (facet NamedList) from + * {@code ["col1", "col2"]} (plain string list). + */ +class JsonResponseParser extends ResponseParser { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String getWriterType() { + return "json"; + } + + @Override + public String getContentType() { + return "application/json; charset=UTF-8"; + } + + @Override + public NamedList processResponse(InputStream body, String encoding) { + try { + return toNamedList(MAPPER.readTree(body)); + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); + } + } + + @Override + public NamedList processResponse(Reader reader) { + try { + return toNamedList(MAPPER.readTree(reader)); + } catch (IOException e) { + throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); + } + } + + private SimpleOrderedMap toNamedList(JsonNode objectNode) { + SimpleOrderedMap result = new SimpleOrderedMap<>(); + objectNode.fields().forEachRemaining(entry -> result.add(entry.getKey(), convertValue(entry.getValue()))); + return result; + } + + private Object convertValue(JsonNode node) { + if (node.isNull()) + return null; + if (node.isBoolean()) + return node.booleanValue(); + if (node.isTextual()) + return node.textValue(); + if (node.isInt()) + return node.intValue(); + if (node.isLong()) + return node.longValue(); + if (node.isDouble() || node.isFloat()) + return node.floatValue(); + if (node.isObject()) + return convertObject(node); + if (node.isArray()) + return convertArray(node); + return node.asText(); + } + + private Object convertObject(JsonNode node) { + // Detect a Solr query result set by the presence of numFound + docs + if (node.has("numFound") && node.has("docs")) { + return toSolrDocumentList(node); + } + return toNamedList(node); + } + + private Object convertArray(JsonNode arrayNode) { + // Detect Solr's flat NamedList encoding: [String, non-String, String, + // non-String, ...] + // Used for facet counts (json.nl=flat default). Distinguished from plain string + // arrays + // by requiring odd-indexed elements to be non-string values. + if (isFlatNamedList(arrayNode)) { + return flatArrayToNamedList(arrayNode); + } + List list = new ArrayList<>(arrayNode.size()); + arrayNode.forEach(element -> list.add(convertValue(element))); + return list; + } + + /** + * Returns true when the array has even length, every even-indexed element is a + * string (the key), and every odd-indexed element is NOT a string (the value). + * This heuristic correctly identifies Solr facet data like + * {@code ["fantasy", 10, "scifi", 5]} while rejecting plain string arrays like + * {@code ["col1", "col2"]}. + */ + private boolean isFlatNamedList(JsonNode arrayNode) { + int size = arrayNode.size(); + if (size == 0 || size % 2 != 0) + return false; + for (int i = 0; i < size; i += 2) { + if (!arrayNode.get(i).isTextual()) + return false; // key must be string + if (arrayNode.get(i + 1).isTextual()) + return false; // value must not be string + } + return true; + } + + private SimpleOrderedMap flatArrayToNamedList(JsonNode arrayNode) { + SimpleOrderedMap result = new SimpleOrderedMap<>(); + for (int i = 0; i < arrayNode.size(); i += 2) { + result.add(arrayNode.get(i).textValue(), convertValue(arrayNode.get(i + 1))); + } + return result; + } + + private SolrDocumentList toSolrDocumentList(JsonNode node) { + SolrDocumentList list = new SolrDocumentList(); + list.setNumFound(node.get("numFound").longValue()); + list.setStart(node.path("start").longValue()); + JsonNode numFoundExact = node.get("numFoundExact"); + if (numFoundExact != null && !numFoundExact.isNull()) { + list.setNumFoundExact(numFoundExact.booleanValue()); + } + JsonNode maxScore = node.get("maxScore"); + if (maxScore != null && !maxScore.isNull()) { + list.setMaxScore(maxScore.floatValue()); + } + node.get("docs").forEach(doc -> list.add(toSolrDocument(doc))); + return list; + } + + private SolrDocument toSolrDocument(JsonNode node) { + SolrDocument doc = new SolrDocument(); + node.fields().forEachRemaining(entry -> { + JsonNode val = entry.getValue(); + if (val.isArray()) { + // Multi-valued field — always a plain list, never a flat NamedList + List values = new ArrayList<>(val.size()); + val.forEach(v -> values.add(convertValue(v))); + doc.setField(entry.getKey(), values); + } else { + doc.setField(entry.getKey(), convertValue(val)); + } + }); + return doc; + } +} 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 7359ccf..cefa735 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 @@ -186,8 +186,9 @@ SolrClient solrClient(SolrConfigurationProperties properties) { } } - // Use with explicit base URL + // Use with explicit base URL; JSON wire format replaces the JavaBin default return new Http2SolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).build(); + .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).withResponseParser(new JsonResponseParser()) + .build(); } } From a387b19f00d91d752d16594cef1fd937cb346924 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 5 Mar 2026 23:25:03 -0500 Subject: [PATCH 02/13] refactor(config): make JsonResponseParser a Spring Bean Extract JsonResponseParser instantiation into a dedicated @Bean method so it can be injected as a dependency into solrClient(), making the wiring explicit and enabling overriding in tests. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .../org/apache/solr/mcp/server/config/SolrConfig.java | 9 +++++++-- .../apache/solr/mcp/server/config/SolrConfigTest.java | 10 +++++----- 2 files changed, 12 insertions(+), 7 deletions(-) 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 cefa735..95f8104 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 @@ -168,7 +168,12 @@ public class SolrConfig { * @see SolrConfigurationProperties#url() */ @Bean - SolrClient solrClient(SolrConfigurationProperties properties) { + JsonResponseParser jsonResponseParser() { + return new JsonResponseParser(); + } + + @Bean + SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser jsonResponseParser) { String url = properties.url(); // Ensure URL is properly formatted for Solr @@ -188,7 +193,7 @@ SolrClient solrClient(SolrConfigurationProperties properties) { // Use with explicit base URL; JSON wire format replaces the JavaBin default return new Http2SolrClient.Builder(url).withConnectionTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).withResponseParser(new JsonResponseParser()) + .withIdleTimeout(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS).withResponseParser(jsonResponseParser) .build(); } } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index 342ddd0..e14c1fb 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -81,7 +81,7 @@ void testUrlNormalization(String inputUrl, String expectedUrl) { SolrConfig solrConfig = new SolrConfig(); // Test URL normalization - SolrClient client = solrConfig.solrClient(testProperties); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); assertNotNull(client); var httpClient = assertInstanceOf(Http2SolrClient.class, client); @@ -101,7 +101,7 @@ void testUrlWithoutTrailingSlash() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); Http2SolrClient httpClient = (Http2SolrClient) client; // Should add trailing slash and solr path @@ -120,7 +120,7 @@ void testUrlWithTrailingSlashButNoSolrPath() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); Http2SolrClient httpClient = (Http2SolrClient) client; // Should add solr path to existing trailing slash @@ -139,7 +139,7 @@ void testUrlWithSolrPathButNoTrailingSlash() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); Http2SolrClient httpClient = (Http2SolrClient) client; // Should add trailing slash @@ -158,7 +158,7 @@ void testUrlAlreadyProperlyFormatted() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); Http2SolrClient httpClient = (Http2SolrClient) client; // Should remain unchanged From ca805998f4778f742c35bd9d7660ea345645661a Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 6 Mar 2026 19:27:46 -0500 Subject: [PATCH 03/13] refactor(config): inject Spring's ObjectMapper into JsonResponseParser Replace the static new ObjectMapper() with Spring's auto-configured ObjectMapper bean injected via constructor. Use MediaType.APPLICATION_JSON_VALUE for the content type constant instead of a raw string literal. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .../solr/mcp/server/config/JsonResponseParser.java | 13 +++++++++---- .../apache/solr/mcp/server/config/SolrConfig.java | 5 +++-- .../solr/mcp/server/config/SolrConfigTest.java | 11 ++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java index 97f5053..b57daf8 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java +++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java @@ -29,6 +29,7 @@ import org.apache.solr.common.SolrException; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; +import org.springframework.http.MediaType; /** * SolrJ {@link ResponseParser} that requests JSON wire format ({@code wt=json}) @@ -67,7 +68,11 @@ */ class JsonResponseParser extends ResponseParser { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private final ObjectMapper mapper; + + JsonResponseParser(ObjectMapper mapper) { + this.mapper = mapper; + } @Override public String getWriterType() { @@ -76,13 +81,13 @@ public String getWriterType() { @Override public String getContentType() { - return "application/json; charset=UTF-8"; + return MediaType.APPLICATION_JSON_VALUE; } @Override public NamedList processResponse(InputStream body, String encoding) { try { - return toNamedList(MAPPER.readTree(body)); + return toNamedList(mapper.readTree(body)); } catch (IOException e) { throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); } @@ -91,7 +96,7 @@ public NamedList processResponse(InputStream body, String encoding) { @Override public NamedList processResponse(Reader reader) { try { - return toNamedList(MAPPER.readTree(reader)); + return toNamedList(mapper.readTree(reader)); } catch (IOException e) { throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON response", e); } 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 95f8104..ed2cf88 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 @@ -16,6 +16,7 @@ */ package org.apache.solr.mcp.server.config; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.concurrent.TimeUnit; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.Http2SolrClient; @@ -168,8 +169,8 @@ public class SolrConfig { * @see SolrConfigurationProperties#url() */ @Bean - JsonResponseParser jsonResponseParser() { - return new JsonResponseParser(); + JsonResponseParser jsonResponseParser(ObjectMapper objectMapper) { + return new JsonResponseParser(objectMapper); } @Bean diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index e14c1fb..086c459 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.Http2SolrClient; import org.apache.solr.mcp.server.TestcontainersConfiguration; @@ -81,7 +82,7 @@ void testUrlNormalization(String inputUrl, String expectedUrl) { SolrConfig solrConfig = new SolrConfig(); // Test URL normalization - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); assertNotNull(client); var httpClient = assertInstanceOf(Http2SolrClient.class, client); @@ -101,7 +102,7 @@ void testUrlWithoutTrailingSlash() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); Http2SolrClient httpClient = (Http2SolrClient) client; // Should add trailing slash and solr path @@ -120,7 +121,7 @@ void testUrlWithTrailingSlashButNoSolrPath() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); Http2SolrClient httpClient = (Http2SolrClient) client; // Should add solr path to existing trailing slash @@ -139,7 +140,7 @@ void testUrlWithSolrPathButNoTrailingSlash() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); Http2SolrClient httpClient = (Http2SolrClient) client; // Should add trailing slash @@ -158,7 +159,7 @@ void testUrlAlreadyProperlyFormatted() { SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); SolrConfig solrConfig = new SolrConfig(); - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser()); + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); Http2SolrClient httpClient = (Http2SolrClient) client; // Should remain unchanged From 4b08938fb3edeb075e520d53c15fc46df913d107 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 6 Mar 2026 19:57:42 -0500 Subject: [PATCH 04/13] test(config): use @JsonTest for URL normalization tests to get Spring's ObjectMapper Extract URL normalization tests from SolrConfigTest into a dedicated SolrConfigUrlNormalizationTest annotated with @JsonTest, so Spring's auto-configured ObjectMapper is injected rather than using new ObjectMapper(). Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .../mcp/server/config/SolrConfigTest.java | 106 --------------- .../SolrConfigUrlNormalizationTest.java | 126 ++++++++++++++++++ 2 files changed, 126 insertions(+), 106 deletions(-) create mode 100644 src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java index 086c459..ce109c6 100644 --- a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigTest.java @@ -18,13 +18,10 @@ import static org.junit.jupiter.api.Assertions.*; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.impl.Http2SolrClient; import org.apache.solr.mcp.server.TestcontainersConfiguration; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; @@ -68,107 +65,4 @@ void testSolrConfigurationProperties() { properties.url()); } - @ParameterizedTest - @CsvSource({"http://localhost:8983, http://localhost:8983/solr", - "http://localhost:8983/, http://localhost:8983/solr", - "http://localhost:8983/solr, http://localhost:8983/solr", - "http://localhost:8983/solr/, http://localhost:8983/solr", - "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr"}) - void testUrlNormalization(String inputUrl, String expectedUrl) { - // Create a test properties object - SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); - - // Create SolrConfig instance - SolrConfig solrConfig = new SolrConfig(); - - // Test URL normalization - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); - assertNotNull(client); - - var httpClient = assertInstanceOf(Http2SolrClient.class, client); - assertEquals(expectedUrl, httpClient.getBaseURL()); - - // Clean up - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithoutTrailingSlash() { - // Test URL without trailing slash branch - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add trailing slash and solr path - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithTrailingSlashButNoSolrPath() { - // Test URL with trailing slash but no solr path branch - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add solr path to existing trailing slash - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlWithSolrPathButNoTrailingSlash() { - // Test URL with solr path but no trailing slash - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should add trailing slash - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } - - @Test - void testUrlAlreadyProperlyFormatted() { - // Test URL that's already properly formatted - SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); - SolrConfig solrConfig = new SolrConfig(); - - SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(new ObjectMapper())); - Http2SolrClient httpClient = (Http2SolrClient) client; - - // Should remain unchanged - assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); - - try { - client.close(); - } catch (Exception e) { - // Ignore close errors in test - } - } } diff --git a/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java new file mode 100644 index 0000000..f90b449 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.config; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.impl.Http2SolrClient; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; + +@JsonTest +class SolrConfigUrlNormalizationTest { + + @Autowired + private ObjectMapper objectMapper; + + @ParameterizedTest + @CsvSource({"http://localhost:8983, http://localhost:8983/solr", + "http://localhost:8983/, http://localhost:8983/solr", + "http://localhost:8983/solr, http://localhost:8983/solr", + "http://localhost:8983/solr/, http://localhost:8983/solr", + "http://localhost:8983/custom/solr/, http://localhost:8983/custom/solr"}) + void testUrlNormalization(String inputUrl, String expectedUrl) { + SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper)); + assertNotNull(client); + + var httpClient = assertInstanceOf(Http2SolrClient.class, client); + assertEquals(expectedUrl, httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlWithoutTrailingSlash() { + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper)); + Http2SolrClient httpClient = (Http2SolrClient) client; + + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlWithTrailingSlashButNoSolrPath() { + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper)); + Http2SolrClient httpClient = (Http2SolrClient) client; + + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlWithSolrPathButNoTrailingSlash() { + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper)); + Http2SolrClient httpClient = (Http2SolrClient) client; + + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } + + @Test + void testUrlAlreadyProperlyFormatted() { + SolrConfigurationProperties testProperties = new SolrConfigurationProperties("http://localhost:8983/solr/"); + SolrConfig solrConfig = new SolrConfig(); + + SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper)); + Http2SolrClient httpClient = (Http2SolrClient) client; + + assertEquals("http://localhost:8983/solr", httpClient.getBaseURL()); + + try { + client.close(); + } catch (Exception e) { + // Ignore close errors in test + } + } +} From 9224e8e2b14ae3f65461c4bf52f4cd60b1f1db4e Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 8 Mar 2026 17:07:30 -0400 Subject: [PATCH 05/13] fix(collection): catch RuntimeException from removed /admin/mbeans in Solr 10 SolrInfoMBeanHandler (and thus the /admin/mbeans endpoint) was removed in Solr 10. When getCacheMetrics() or getHandlerMetrics() call this endpoint on a Solr 10 server, SolrJ throws RemoteSolrException (a RuntimeException) because the server returns an HTML 404 page instead of JSON. Widen the catch in both methods to include RuntimeException so the server degrades gracefully (returning null for cache/handler stats) rather than propagating the exception. The integration tests already handle null stats, so all tests now pass with solr:10-slim. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: adityamparikh --- .../solr/mcp/server/metadata/CollectionService.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java index 59a83ed..1cd65eb 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java @@ -609,7 +609,9 @@ public CacheStats getCacheMetrics(String collection) { } return stats; - } catch (SolrServerException | IOException e) { + } catch (SolrServerException | IOException | RuntimeException e) { + // RuntimeException covers SolrException subclasses (e.g. RemoteSolrException) + // thrown when the /admin/mbeans endpoint is unavailable (removed in Solr 10). return null; // Return null instead of empty object } } @@ -781,7 +783,9 @@ public HandlerStats getHandlerMetrics(String collection) { } return stats; - } catch (SolrServerException | IOException e) { + } catch (SolrServerException | IOException | RuntimeException e) { + // RuntimeException covers SolrException subclasses (e.g. RemoteSolrException) + // thrown when the /admin/mbeans endpoint is unavailable (removed in Solr 10). return null; // Return null instead of empty object } } From a75f28fa52d142e48a2d2aa3e543ba5e47449d49 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 6 Mar 2026 00:08:04 -0500 Subject: [PATCH 06/13] feat(ci): add Solr 9.10 and 10 compatibility testing Add Solr 9.10 and 10 to the CI compatibility matrix, running integration tests against all supported versions (8.11, 9.4, 9.9, 9.10, 10) on every PR and push to main. Also update AGENTS.md to document Solr 10 compatibility status: the /admin/mbeans endpoint removal is handled gracefully, and all other functionality is verified working with solr:10-slim. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .github/workflows/build-and-publish.yml | 35 ++++++++++++++++++++++++- AGENTS.md | 20 +++++++++----- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index eacba70..27c3013 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -175,7 +175,40 @@ jobs: retention-days: 7 # ============================================================================ - # Job 2: Publish Docker Images + # Job 2: Solr Version Compatibility Tests + # ============================================================================ + # Tests the server against multiple Solr versions using Testcontainers. + # Runs in parallel so each Solr version is an independent job. + # Tested versions: 8.11, 9.4, 9.9, 9.10, 10 + # ============================================================================ + solr-compatibility: + name: Solr ${{ matrix.solr-version }} Compatibility + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + solr-version: + - "8.11-slim" + - "9.4-slim" + - "9.9-slim" + - "9.10-slim" + - "10-slim" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java + uses: ./.github/actions/setup-java + + - name: Run tests against Solr ${{ matrix.solr-version }} + env: + SOLR_VERSION: ${{ matrix.solr-version }} + run: ./gradlew test "-Dsolr.test.image=solr:${SOLR_VERSION}" + + # ============================================================================ + # Job 3: Publish Docker Images # ============================================================================ # This job builds multi-platform Docker images using Jib and publishes them # to GitHub Container Registry (GHCR) and Docker Hub. diff --git a/AGENTS.md b/AGENTS.md index 57dd384..91f9d58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,18 +81,24 @@ The Solr Docker image used in tests is configurable via the `solr.test.image` sy ./gradlew test -Dsolr.test.image=solr:8.11-slim # Solr 8.11 ./gradlew test -Dsolr.test.image=solr:9.4-slim # Solr 9.4 ./gradlew test -Dsolr.test.image=solr:9.9-slim # Solr 9.9 (default) +./gradlew test -Dsolr.test.image=solr:9.10-slim # Solr 9.10 +./gradlew test -Dsolr.test.image=solr:10-slim # Solr 10 ``` -**Tested compatible versions:** 8.11, 9.4, 9.9 +**Tested compatible versions:** 8.11, 9.4, 9.9, 9.10, 10 -### Solr 10 Compatibility Notes +### Solr 10 Compatibility -Solr 10 introduces breaking changes that will require updates to this project: +Solr 10.0.0 is fully supported with the JSON wire format. The `/admin/mbeans` endpoint was +removed in Solr 10; `getCacheMetrics()` and `getHandlerMetrics()` now catch `RuntimeException` +(which covers `RemoteSolrException`) so they degrade gracefully and return `null`. Tests that +check `cacheStats` and `handlerStats` already handle `null` values. -- **MBeans removal:** `SolrInfoMBeanHandler` is removed. `CollectionService.getCollectionStats()` uses `/admin/mbeans` for cache and handler metrics — this will need to migrate to the `/admin/metrics` endpoint or OpenTelemetry. -- **Metrics migration:** Dropwizard metrics replaced by OpenTelemetry. All metric names switch to snake_case. JMX, Prometheus exporter, SLF4J, and Graphite reporters are removed. -- **SolrJ base URL:** SolrClient now only accepts root URLs (e.g., `http://host:8983/solr`). This project already uses root URLs with per-request collection names, so **no change needed** here. -- **SolrJ dependency:** Upgrade `solr-solrj` from 9.x to 10.x in `gradle/libs.versions.toml`. The Jetty BOM alignment (`jetty = "10.0.22"`) will also need updating since Solr 10 uses Jetty 12.x. +Remaining known differences from Solr 9: +- **`/admin/mbeans` removed:** Cache and handler stats from `getCollectionStats()` will always be `null` on Solr 10. A future migration to `/admin/metrics` will restore these metrics. +- **Metrics migration:** Dropwizard metrics replaced by OpenTelemetry. Metric names switch to snake_case in Solr 10. +- **SolrJ base URL:** Already uses root URLs — **no change needed**. +- **SolrJ 10.x dependency:** Not yet on Maven Central (as of 2026-03-06); tests use SolrJ 9.x against a Solr 10 server. Update `solr-solrj` and Jetty BOM when 10.x is released. ## Key Configuration From e8b26567ff4240915fd586588a93df2cbee2d90d Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 5 Mar 2026 21:53:51 -0500 Subject: [PATCH 07/13] feat(collection): add create-collection tool and dedicated package Move CollectionService, CollectionUtils, and Dtos from the metadata package into a new dedicated collection package. This separates collection management from schema introspection (SchemaService stays in metadata). Add create-collection MCP tool to CollectionService: - Accepts name (required), configSet, numShards, replicationFactor - Defaults: configSet=_default, numShards=1, replicationFactor=1 - Uses CollectionAdminRequest.createCollection() for both SolrCloud and standalone Solr via Http2SolrClient - Returns CollectionCreationResult DTO with name, success, message, and createdAt timestamp Add CollectionCreationResult record to Dtos.java. Update unit tests with correct 2-arg Mockito stubs to match CollectionAdminRequest.process() call signature. Add integration test asserting the new collection appears in listCollections() after creation. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .../java/org/apache/solr/mcp/server/Main.java | 41 +---------- .../CollectionService.java | 71 +++++++++++++++++-- .../CollectionUtils.java | 2 +- .../server/{metadata => collection}/Dtos.java | 26 ++++++- .../org/apache/solr/mcp/server/MainTest.java | 2 +- .../mcp/server/McpToolRegistrationTest.java | 2 +- .../CollectionServiceIntegrationTest.java | 18 ++++- .../CollectionServiceTest.java | 59 ++++++++++++++- .../CollectionUtilsTest.java | 2 +- 9 files changed, 173 insertions(+), 50 deletions(-) rename src/main/java/org/apache/solr/mcp/server/{metadata => collection}/CollectionService.java (92%) rename src/main/java/org/apache/solr/mcp/server/{metadata => collection}/CollectionUtils.java (99%) rename src/main/java/org/apache/solr/mcp/server/{metadata => collection}/Dtos.java (94%) rename src/test/java/org/apache/solr/mcp/server/{metadata => collection}/CollectionServiceIntegrationTest.java (92%) rename src/test/java/org/apache/solr/mcp/server/{metadata => collection}/CollectionServiceTest.java (93%) rename src/test/java/org/apache/solr/mcp/server/{metadata => collection}/CollectionUtilsTest.java (99%) diff --git a/src/main/java/org/apache/solr/mcp/server/Main.java b/src/main/java/org/apache/solr/mcp/server/Main.java index 943d3e7..f2970f5 100644 --- a/src/main/java/org/apache/solr/mcp/server/Main.java +++ b/src/main/java/org/apache/solr/mcp/server/Main.java @@ -16,8 +16,8 @@ */ package org.apache.solr.mcp.server; +import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; -import org.apache.solr.mcp.server.metadata.CollectionService; import org.apache.solr.mcp.server.metadata.SchemaService; import org.apache.solr.mcp.server.search.SearchService; import org.springframework.boot.SpringApplication; @@ -107,44 +107,7 @@ */ @SpringBootApplication public class Main { - - /** - * Main application entry point that starts the Spring Boot application. - * - *

- * This method initializes the Spring application context, configures all - * service beans, establishes Solr connectivity, and begins listening for MCP - * client connections via standard input/output. - * - *

- * Startup Process: - * - *

    - *
  1. Initialize Spring Boot application context - *
  2. Load configuration properties from various sources - *
  3. Create and configure SolrClient bean - *
  4. Initialize all service beans with dependency injection - *
  5. Register MCP tools from service methods - *
  6. Start MCP server listening on stdio - *
- * - *

- * Error Handling: - * - *

- * Startup failures typically indicate configuration issues such as: - * - *

    - *
  • Missing or invalid Solr URL configuration - *
  • Network connectivity issues to Solr server - *
  • Missing required dependencies or classpath issues - *
- * - * @param args - * command-line arguments passed to the application - * @see SpringApplication#run(Class, String...) - */ - public static void main(String[] args) { + static void main(String[] args) { SpringApplication.run(Main.class, args); } } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java similarity index 92% rename from src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java rename to src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java index 1cd65eb..39d7fff 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java @@ -14,11 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.collection; -import static org.apache.solr.mcp.server.metadata.CollectionUtils.getFloat; -import static org.apache.solr.mcp.server.metadata.CollectionUtils.getInteger; -import static org.apache.solr.mcp.server.metadata.CollectionUtils.getLong; +import static org.apache.solr.mcp.server.collection.CollectionUtils.getFloat; +import static org.apache.solr.mcp.server.collection.CollectionUtils.getInteger; +import static org.apache.solr.mcp.server.collection.CollectionUtils.getLong; import static org.apache.solr.mcp.server.util.JsonUtils.toJson; import com.fasterxml.jackson.databind.ObjectMapper; @@ -249,6 +249,18 @@ public class CollectionService { /** Error message prefix for collection not found exceptions */ private static final String COLLECTION_NOT_FOUND_ERROR = "Collection not found: "; + /** Default configset name used when none is specified */ + private static final String DEFAULT_CONFIGSET = "_default"; + + /** Default number of shards for new collections */ + private static final int DEFAULT_NUM_SHARDS = 1; + + /** Default replication factor for new collections */ + private static final int DEFAULT_REPLICATION_FACTOR = 1; + + /** Error message for blank collection name validation */ + private static final String BLANK_COLLECTION_NAME_ERROR = "Collection name must not be blank"; + /** SolrJ client for communicating with Solr server */ private final SolrClient solrClient; @@ -1042,4 +1054,55 @@ public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection return new SolrHealthStatus(false, e.getMessage(), null, null, new Date(), null, null, null); } } + + /** + * Creates a new Solr collection (SolrCloud) or core (standalone Solr). + * + *

+ * Automatically detects the deployment type and uses the appropriate API: + * + *

+ * Uses the Collections API, which works with any SolrClient pointing to a + * SolrCloud deployment. + * + *

+ * Optional parameters default to sensible values when not provided by the MCP + * client: configSet defaults to {@value #DEFAULT_CONFIGSET}, numShards and + * replicationFactor both default to 1. + * + * @param name + * the name of the collection to create (must not be blank) + * @param configSet + * the configset name (optional, defaults to + * {@value #DEFAULT_CONFIGSET}) + * @param numShards + * number of shards (optional, defaults to 1) + * @param replicationFactor + * replication factor (optional, defaults to 1) + * @return result describing the outcome of the creation operation + * @throws IllegalArgumentException + * if the collection name is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @McpTool(name = "create-collection", description = "Create a new 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) + throws SolrServerException, IOException { + + String effectiveConfigSet = configSet != null ? configSet : DEFAULT_CONFIGSET; + int effectiveShards = numShards != null ? numShards : DEFAULT_NUM_SHARDS; + int effectiveRf = replicationFactor != null ? replicationFactor : DEFAULT_REPLICATION_FACTOR; + + CollectionAdminRequest.createCollection(name, effectiveConfigSet, effectiveShards, effectiveRf) + .process(solrClient); + + return new CollectionCreationResult(name, true, "Collection created successfully", new Date()); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java b/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java similarity index 99% rename from src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java rename to src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java index bd6e20a..9e5ea92 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/CollectionUtils.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionUtils.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.collection; import org.apache.solr.common.util.NamedList; diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java b/src/main/java/org/apache/solr/mcp/server/collection/Dtos.java similarity index 94% rename from src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java rename to src/main/java/org/apache/solr/mcp/server/collection/Dtos.java index 7a04ac3..d5569b6 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/Dtos.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/Dtos.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.collection; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -477,3 +477,27 @@ record SolrHealthStatus( /** Additional status information or state description */ String status) { } + +/** + * Result of a collection creation operation. + * + *

+ * Returned by the {@code create-collection} MCP tool to communicate the outcome + * of a collection creation request. On success, {@code success} is {@code true} + * and {@code createdAt} records when the operation completed. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +record CollectionCreationResult( + /** Name of the collection that was created */ + String name, + + /** Whether the collection was successfully created */ + boolean success, + + /** Optional message describing the outcome */ + String message, + + /** Timestamp when the collection was created, formatted as ISO 8601 */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date createdAt) { +} diff --git a/src/test/java/org/apache/solr/mcp/server/MainTest.java b/src/test/java/org/apache/solr/mcp/server/MainTest.java index db6fdb7..1b9ae42 100644 --- a/src/test/java/org/apache/solr/mcp/server/MainTest.java +++ b/src/test/java/org/apache/solr/mcp/server/MainTest.java @@ -16,8 +16,8 @@ */ package org.apache.solr.mcp.server; +import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; -import org.apache.solr.mcp.server.metadata.CollectionService; import org.apache.solr.mcp.server.metadata.SchemaService; import org.apache.solr.mcp.server.search.SearchService; import org.junit.jupiter.api.Test; diff --git a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java index c0861c9..af79754 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -22,8 +22,8 @@ import java.lang.reflect.Parameter; import java.util.Arrays; import java.util.List; +import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; -import org.apache.solr.mcp.server.metadata.CollectionService; import org.apache.solr.mcp.server.metadata.SchemaService; import org.apache.solr.mcp.server.search.SearchService; import org.junit.jupiter.api.Test; diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java similarity index 92% rename from src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java rename to src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java index 0fa9684..c520bd0 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.collection; import static org.junit.jupiter.api.Assertions.*; @@ -243,4 +243,20 @@ void testCollectionNameExtraction() { assertEquals("", collectionService.extractCollectionName(""), "Empty string should return empty string"); } + + @Test + void createCollection_createsAndListable() throws Exception { + String name = "mcp_test_create_" + System.currentTimeMillis(); + + CollectionCreationResult result = collectionService.createCollection(name, null, null, null); + + assertTrue(result.success(), "Collection creation should succeed"); + assertEquals(name, result.name(), "Result should contain the collection name"); + assertNotNull(result.createdAt(), "Creation timestamp should be set"); + + List collections = collectionService.listCollections(); + boolean collectionExists = collections.contains(name) + || collections.stream().anyMatch(col -> col.startsWith(name + "_shard")); + assertTrue(collectionExists, "Newly created collection should appear in list (found: " + collections + ")"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java similarity index 93% rename from src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java rename to src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java index 19888ae..ed5bca8 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.collection; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -894,4 +894,61 @@ private NamedList createCompleteHandlerData() { return mbeans; } + + // createCollection tests + @Test + void createCollection_success_cloudClient() throws Exception { + CloudSolrClient cloudClient = mock(CloudSolrClient.class); + when(cloudClient.request(any(), any())).thenReturn(new NamedList<>()); + + CollectionService service = new CollectionService(cloudClient, objectMapper); + CollectionCreationResult result = service.createCollection("new_collection", "_default", 1, 1); + + assertNotNull(result); + assertTrue(result.success()); + assertEquals("new_collection", result.name()); + assertNotNull(result.createdAt()); + } + + @Test + void createCollection_success_standaloneClient() throws Exception { + when(solrClient.request(any(), isNull())).thenReturn(new NamedList<>()); + + CollectionCreationResult result = collectionService.createCollection("new_core", null, null, null); + + assertNotNull(result); + assertTrue(result.success()); + assertEquals("new_core", result.name()); + assertNotNull(result.createdAt()); + } + + @Test + void createCollection_defaultsApplied() throws Exception { + CloudSolrClient cloudClient = mock(CloudSolrClient.class); + when(cloudClient.request(any(), any())).thenReturn(new NamedList<>()); + + CollectionService service = new CollectionService(cloudClient, objectMapper); + CollectionCreationResult result = service.createCollection("defaults_collection", null, null, null); + + assertTrue(result.success()); + assertEquals("defaults_collection", result.name()); + } + + @Test + void createCollection_blankName_throwsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> collectionService.createCollection(" ", null, null, null)); + } + + @Test + void createCollection_emptyName_throwsIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> collectionService.createCollection("", null, null, null)); + } + + @Test + void createCollection_solrException_propagates() throws Exception { + when(solrClient.request(any(), isNull())).thenThrow(new SolrServerException("Solr error")); + + assertThrows(SolrServerException.class, + () -> collectionService.createCollection("fail_core", null, null, null)); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java similarity index 99% rename from src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java rename to src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java index 2365494..a4f9a7d 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/CollectionUtilsTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionUtilsTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.collection; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; From ec5a462c8277f8390aa6e0f80c19af1f78d87c2b Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 5 Mar 2026 21:54:01 -0500 Subject: [PATCH 08/13] chore(data): add DevNexus 2026 conference schedule sample data 116 session documents covering all tracks and days (March 4-6 2026) for use with the create-collection and index-documents MCP tools. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- mydata/devnexus-2026.json | 1626 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1626 insertions(+) create mode 100644 mydata/devnexus-2026.json diff --git a/mydata/devnexus-2026.json b/mydata/devnexus-2026.json new file mode 100644 index 0000000..8f7d050 --- /dev/null +++ b/mydata/devnexus-2026.json @@ -0,0 +1,1626 @@ +[ + { + "id": "devnexus-2026-001", + "conference": "DevNexus 2026", + "title": "Event Driven Architecture & Event Streaming Workshop", + "speakers": ["Daniel Hinojosa"], + "track": "Workshop", + "room": "TBD", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "09:00", + "start_datetime": "2026-03-04T09:00:00Z", + "session_type": "Workshop", + "tags": ["event-driven", "event-streaming", "architecture", "workshop"] + }, + { + "id": "devnexus-2026-002", + "conference": "DevNexus 2026", + "title": "Fundamentals of Software Engineering In the Age of AI Workshop", + "speakers": ["Nathaniel Schutta", "Dan Vega"], + "track": "Practices", + "room": "302", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "09:00", + "start_datetime": "2026-03-04T09:00:00Z", + "session_type": "Workshop", + "tags": ["software-engineering", "ai", "practices", "workshop"] + }, + { + "id": "devnexus-2026-003", + "conference": "DevNexus 2026", + "title": "Hands-On: Building Agents with Spring AI, MCP, Java, and Amazon Bedrock", + "speakers": ["James Ward", "Josh Long"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "09:00", + "start_datetime": "2026-03-04T09:00:00Z", + "session_type": "Workshop", + "tags": ["spring-ai", "mcp", "java", "amazon-bedrock", "agents", "workshop"] + }, + { + "id": "devnexus-2026-004", + "conference": "DevNexus 2026", + "title": "Agentic, Assistive & Predictive AI Patterns", + "speakers": ["Rohit Bhardwaj"], + "track": "AI in Practice", + "room": "311", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "13:00", + "start_datetime": "2026-03-04T13:00:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "patterns", "predictive-ai"] + }, + { + "id": "devnexus-2026-005", + "conference": "DevNexus 2026", + "title": "Cruising Along with Java: From Java 9 to 25", + "speakers": ["Venkat Subramaniam"], + "track": "Core Java", + "room": "301", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "13:00", + "start_datetime": "2026-03-04T13:00:00Z", + "session_type": "Talk", + "tags": ["java", "java-25", "language-evolution", "jvm"] + }, + { + "id": "devnexus-2026-006", + "conference": "DevNexus 2026", + "title": "Modernize Your Apps in Days with AI Agents", + "speakers": ["Brian Benz", "Ayan Gupta"], + "track": "Gen AI", + "room": "316", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "13:00", + "start_datetime": "2026-03-04T13:00:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "modernization", "generative-ai"] + }, + { + "id": "devnexus-2026-007", + "conference": "DevNexus 2026", + "title": "Networking Happy Hour", + "speakers": [], + "track": "Social", + "room": "TBD", + "day": "Day 0", + "date": "2026-03-04", + "start_time": "18:15", + "start_datetime": "2026-03-04T18:15:00Z", + "session_type": "Social", + "tags": ["networking", "social"] + }, + { + "id": "devnexus-2026-008", + "conference": "DevNexus 2026", + "title": "It's Up To Java Developers to Fix Enterprise AI", + "speakers": ["Rod Johnson"], + "track": "Keynote", + "room": "411/412", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "09:00", + "start_datetime": "2026-03-05T09:00:00Z", + "session_type": "Keynote", + "tags": ["keynote", "java", "ai", "enterprise"] + }, + { + "id": "devnexus-2026-009", + "conference": "DevNexus 2026", + "title": "Design Patterns for Multi-Agent Systems", + "speakers": ["Brian Sam-Bodden"], + "track": "AI Tools", + "room": "315", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "design-patterns", "multi-agent"] + }, + { + "id": "devnexus-2026-010", + "conference": "DevNexus 2026", + "title": "AI for Busy Java Developers", + "speakers": ["Frank Greco"], + "track": "AI in Practice", + "room": "311", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "java", "practical"] + }, + { + "id": "devnexus-2026-011", + "conference": "DevNexus 2026", + "title": "Unbundling of the Cloud Data Warehouse: Open Source Databases and Data Lakes", + "speakers": ["Zoe Steinkamp"], + "track": "Architecture", + "room": "314", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["architecture", "data-warehouse", "open-source", "data-lakes", "cloud"] + }, + { + "id": "devnexus-2026-012", + "conference": "DevNexus 2026", + "title": "To Java 26 and Beyond!", + "speakers": ["Billy Korando"], + "track": "Core Java", + "room": "301", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["java", "java-26", "language-evolution", "jvm"] + }, + { + "id": "devnexus-2026-013", + "conference": "DevNexus 2026", + "title": "What's New in Spring Boot 4.0 & What's Arriving in 4.1", + "speakers": ["Phil Webb"], + "track": "Frameworks", + "room": "313", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-boot", "spring-boot-4", "frameworks"] + }, + { + "id": "devnexus-2026-014", + "conference": "DevNexus 2026", + "title": "Agents, Tools, and MCP, Oh My! Next-Level AI Concepts for Developers", + "speakers": ["Jennifer Reif"], + "track": "Gen AI", + "room": "316", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "mcp", "agents", "generative-ai", "tools"] + }, + { + "id": "devnexus-2026-015", + "conference": "DevNexus 2026", + "title": "The Mentorship Hub", + "speakers": ["Bruno Souza", "Luiz Real"], + "track": "Community", + "room": "Open-Source Cafe", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "09:00", + "start_datetime": "2026-03-05T09:00:00Z", + "session_type": "Community", + "tags": ["mentorship", "community", "career"] + }, + { + "id": "devnexus-2026-016", + "conference": "DevNexus 2026", + "title": "Refactoring RN", + "speakers": ["Aaron McClennen"], + "track": "Practices", + "room": "302", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["refactoring", "practices", "code-quality"] + }, + { + "id": "devnexus-2026-017", + "conference": "DevNexus 2026", + "title": "Life After Dad Joke Apps - Building \"Enterprise Grade\" AI Frameworks for Java", + "speakers": ["Kevin Strohmeyer", "Josh Long", "Rod Johnson", "Adib Saikali"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["spring", "java", "ai", "enterprise", "frameworks"] + }, + { + "id": "devnexus-2026-018", + "conference": "DevNexus 2026", + "title": "Zero Migration Java: Stay Current Without Breaking Your App", + "speakers": ["Yee-Kang Chang"], + "track": "Security", + "room": "304", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["java", "migration", "security", "compatibility"] + }, + { + "id": "devnexus-2026-019", + "conference": "DevNexus 2026", + "title": "Building Engineers or Teaching Technicians?", + "speakers": ["Jonathon Graf"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["leadership", "career", "education", "engineering-culture"] + }, + { + "id": "devnexus-2026-020", + "conference": "DevNexus 2026", + "title": "How I Automated My Life with MCP Servers", + "speakers": ["Cedric Clyburn"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "10:00", + "start_datetime": "2026-03-05T10:00:00Z", + "session_type": "Talk", + "tags": ["mcp", "automation", "tools", "productivity"] + }, + { + "id": "devnexus-2026-021", + "conference": "DevNexus 2026", + "title": "Brokk: An AI-Native Code Platform for Java, in Java", + "speakers": ["Jonathan Ellis"], + "track": "AI Tools", + "room": "315", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["ai", "java", "tools", "code-platform"] + }, + { + "id": "devnexus-2026-022", + "conference": "DevNexus 2026", + "title": "The Wrong Reasons to Build an MCP Server", + "speakers": ["Daniel Oh"], + "track": "AI in Practice", + "room": "311", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["mcp", "ai", "practical", "architecture"] + }, + { + "id": "devnexus-2026-023", + "conference": "DevNexus 2026", + "title": "JUnit 6 + Exploring the Testing Ecosystem", + "speakers": ["Jeanne Boyarsky"], + "track": "Core Java", + "room": "301", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["java", "testing", "junit", "junit-6"] + }, + { + "id": "devnexus-2026-024", + "conference": "DevNexus 2026", + "title": "Spring Data 4: Data Access Revisited", + "speakers": ["Chris Bono"], + "track": "Frameworks", + "room": "313", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-data", "frameworks", "data-access"] + }, + { + "id": "devnexus-2026-025", + "conference": "DevNexus 2026", + "title": "10 Tools & Tips to Upgrade Your Java Code with AI", + "speakers": ["Vinicius Senger", "Jonathan Vogel"], + "track": "Gen AI", + "room": "316", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["ai", "java", "tools", "generative-ai", "productivity"] + }, + { + "id": "devnexus-2026-026", + "conference": "DevNexus 2026", + "title": "Lean Software Development", + "speakers": ["Leigh Griffin"], + "track": "Practices", + "room": "302", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["lean", "practices", "software-development", "agile"] + }, + { + "id": "devnexus-2026-027", + "conference": "DevNexus 2026", + "title": "Modern API Versioning and Enterprise-Ready AI Enablement with Spring Cloud Gateway", + "speakers": ["Spencer Gibb", "Chris Sterling"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-cloud", "api", "api-versioning", "gateway", "enterprise"] + }, + { + "id": "devnexus-2026-028", + "conference": "DevNexus 2026", + "title": "Bootiful Spring Security", + "speakers": ["Josh Long", "Rob Winch"], + "track": "Security", + "room": "304", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-security", "security"] + }, + { + "id": "devnexus-2026-029", + "conference": "DevNexus 2026", + "title": "Modern Architectures for Software Development Leaders", + "speakers": ["Venkat Subramaniam"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["architecture", "leadership", "software-development"] + }, + { + "id": "devnexus-2026-030", + "conference": "DevNexus 2026", + "title": "Unlocking Engineering Productivity with IDE-Based Coding Agents", + "speakers": ["Josh Kurz"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "11:20", + "start_datetime": "2026-03-05T11:20:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "ide", "productivity", "tools"] + }, + { + "id": "devnexus-2026-031", + "conference": "DevNexus 2026", + "title": "Fundamentals of Software Engineering In the Age of AI", + "speakers": ["Nathaniel Schutta", "Dan Vega"], + "track": "AI Tools", + "room": "315", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["ai", "software-engineering", "practices", "tools"] + }, + { + "id": "devnexus-2026-032", + "conference": "DevNexus 2026", + "title": "10 Things I Hate About AI", + "speakers": ["Cody Frenzel", "Laurie Lay"], + "track": "AI in Practice", + "room": "311", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["ai", "practical", "critique"] + }, + { + "id": "devnexus-2026-033", + "conference": "DevNexus 2026", + "title": "Java Cloud Optimization: Serverless, Native, and CRaC – Unleash Peak Performance", + "speakers": ["Rustam Mehmandarov"], + "track": "Architecture", + "room": "314", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["java", "cloud", "serverless", "native", "crac", "performance", "architecture"] + }, + { + "id": "devnexus-2026-034", + "conference": "DevNexus 2026", + "title": "Java Performance: Beyond Simple Request Latencies", + "speakers": ["John Ceccarelli", "Simon Ritter"], + "track": "Core Java", + "room": "301", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["java", "performance", "jvm", "latency"] + }, + { + "id": "devnexus-2026-035", + "conference": "DevNexus 2026", + "title": "Beyond SWE-Bench: Enterprise Java AI Agents and Real-World Development Benchmarks", + "speakers": ["Mark Pollack"], + "track": "Frameworks", + "room": "313", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "java", "enterprise", "benchmarks", "spring"] + }, + { + "id": "devnexus-2026-036", + "conference": "DevNexus 2026", + "title": "Building AI Agents with Spring & MCP", + "speakers": ["James Ward", "Josh Long"], + "track": "Gen AI", + "room": "316", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-ai", "mcp", "agents", "generative-ai"] + }, + { + "id": "devnexus-2026-037", + "conference": "DevNexus 2026", + "title": "Git Features You Aren't Using", + "speakers": ["Raju Gandhi"], + "track": "Practices", + "room": "302", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["git", "practices", "tools", "version-control"] + }, + { + "id": "devnexus-2026-038", + "conference": "DevNexus 2026", + "title": "The Modern Spring Workflow: Enterprise-Ready Delivery and AI-Boosted Coding with Tanzu", + "speakers": ["Chris Sterling", "Cora Iberkleid"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["spring", "tanzu", "ai", "enterprise", "cloud-native"] + }, + { + "id": "devnexus-2026-039", + "conference": "DevNexus 2026", + "title": "Building Trustworthy and Reliable LLM Applications", + "speakers": ["Alex Soto", "Markus Eisele"], + "track": "Security", + "room": "304", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["llm", "ai", "security", "reliability", "trust"] + }, + { + "id": "devnexus-2026-040", + "conference": "DevNexus 2026", + "title": "The Accidental Leader: How to Succeed When You Weren't Planning to Lead", + "speakers": ["Emily Harden"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "13:20", + "start_datetime": "2026-03-05T13:20:00Z", + "session_type": "Talk", + "tags": ["leadership", "career", "management"] + }, + { + "id": "devnexus-2026-041", + "conference": "DevNexus 2026", + "title": "Hands-on Embabel", + "speakers": ["Rod Johnson"], + "track": "AI Tools", + "room": "315", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["ai", "embabel", "tools", "agents"] + }, + { + "id": "devnexus-2026-042", + "conference": "DevNexus 2026", + "title": "Inside MCP: Live Protocol Messages, Real-Time Flows, and Smarter Agents", + "speakers": ["David Parry"], + "track": "AI in Practice", + "room": "311", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["mcp", "ai", "agents", "protocol"] + }, + { + "id": "devnexus-2026-043", + "conference": "DevNexus 2026", + "title": "Modular Monoliths: A Happy Middle", + "speakers": ["Raju Gandhi"], + "track": "Architecture", + "room": "314", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["architecture", "modular-monolith", "microservices"] + }, + { + "id": "devnexus-2026-044", + "conference": "DevNexus 2026", + "title": "Beyond Default Settings: Optimizing Java on K8s with AI-Driven Performance Tuning", + "speakers": ["Stefano Doni"], + "track": "Core Java", + "room": "301", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["java", "kubernetes", "performance", "ai", "jvm"] + }, + { + "id": "devnexus-2026-045", + "conference": "DevNexus 2026", + "title": "Supercharge Your Applications with Java, Graphs, and a Touch of AI", + "speakers": ["Jennifer Reif", "Erin Schnabel"], + "track": "Frameworks", + "room": "313", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["java", "graphs", "ai", "frameworks", "neo4j"] + }, + { + "id": "devnexus-2026-046", + "conference": "DevNexus 2026", + "title": "From Monolith to AI Agent: Modernizing Java Systems with MCP", + "speakers": ["Theo Lebrun"], + "track": "Gen AI", + "room": "316", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["mcp", "ai", "java", "modernization", "agents", "generative-ai"] + }, + { + "id": "devnexus-2026-047", + "conference": "DevNexus 2026", + "title": "Clear Up Messy Code with Refactoring Maneuvers in IntelliJ IDEA", + "speakers": ["Ted M. Young"], + "track": "Practices", + "room": "302", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["refactoring", "intellij", "practices", "code-quality", "ide"] + }, + { + "id": "devnexus-2026-048", + "conference": "DevNexus 2026", + "title": "Building AI Agents with Spring AI and Tanzu Platform", + "speakers": ["Josh Long", "Adib Saikali"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-ai", "agents", "tanzu", "ai"] + }, + { + "id": "devnexus-2026-049", + "conference": "DevNexus 2026", + "title": "The Missing Protocol: How MCP Bridges LLMs and Data Streams", + "speakers": ["Viktor Gamov"], + "track": "Security", + "room": "304", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["mcp", "llm", "data-streaming", "kafka", "security"] + }, + { + "id": "devnexus-2026-050", + "conference": "DevNexus 2026", + "title": "How to Run a 1 on 1 for Everyone (Not Just Managers!)", + "speakers": ["Alex Riviere"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["leadership", "management", "one-on-one", "career"] + }, + { + "id": "devnexus-2026-051", + "conference": "DevNexus 2026", + "title": "The Engineer's Guide to Socialization", + "speakers": ["Nerando Johnson"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "14:50", + "start_datetime": "2026-03-05T14:50:00Z", + "session_type": "Talk", + "tags": ["soft-skills", "career", "communication"] + }, + { + "id": "devnexus-2026-052", + "conference": "DevNexus 2026", + "title": "The Evolution of Memory in Humans and AI Agents", + "speakers": ["Raphael De Lio"], + "track": "AI Tools", + "room": "315", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "memory", "tools"] + }, + { + "id": "devnexus-2026-053", + "conference": "DevNexus 2026", + "title": "When One Agent Isn't Enough: Experiments with Multi-Agent AI", + "speakers": ["Kenneth Kousen"], + "track": "AI in Practice", + "room": "311", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["ai", "multi-agent", "agents", "practical"] + }, + { + "id": "devnexus-2026-054", + "conference": "DevNexus 2026", + "title": "From Legacy to Cloud-Native: Effective and Scalable Java Modernization with Automation", + "speakers": ["Emily Jiang"], + "track": "Architecture", + "room": "314", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["java", "cloud-native", "modernization", "automation", "architecture"] + }, + { + "id": "devnexus-2026-055", + "conference": "DevNexus 2026", + "title": "Developer Career Masterplan: 15 Steps to Grow Beyond Senior Developer", + "speakers": ["Bruno Souza"], + "track": "Core Java", + "room": "301", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["career", "java", "developer", "growth"] + }, + { + "id": "devnexus-2026-056", + "conference": "DevNexus 2026", + "title": "What Every Spring Developer Should Know About Jakarta EE", + "speakers": ["Ivar Grimstad"], + "track": "Frameworks", + "room": "313", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["spring", "jakarta-ee", "frameworks", "java"] + }, + { + "id": "devnexus-2026-057", + "conference": "DevNexus 2026", + "title": "Architecting Microservices for Agentic AI Integration", + "speakers": ["Rohit Bhardwaj"], + "track": "Gen AI", + "room": "316", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["microservices", "ai", "agents", "architecture", "generative-ai"] + }, + { + "id": "devnexus-2026-058", + "conference": "DevNexus 2026", + "title": "The Ultimate Showdown of Database Migration Tools", + "speakers": ["Pasha Finkelshteyn", "Anton Arhipov"], + "track": "Practices", + "room": "302", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["database", "migration", "liquibase", "flyway", "practices"] + }, + { + "id": "devnexus-2026-059", + "conference": "DevNexus 2026", + "title": "Enabling High-Throughput, Low-Latency Inference for Your AI Applications", + "speakers": ["Cora Iberkleid"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["ai", "inference", "performance", "spring", "llm"] + }, + { + "id": "devnexus-2026-060", + "conference": "DevNexus 2026", + "title": "Privacy in Design (PbD) in DevSecOps", + "speakers": ["Anitha Dakamarri"], + "track": "Security", + "room": "304", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["security", "privacy", "devsecops", "practices"] + }, + { + "id": "devnexus-2026-061", + "conference": "DevNexus 2026", + "title": "Thriving in an Evolving Software Industry", + "speakers": ["Nathaniel Schutta", "Glenn Renfro"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["career", "leadership", "software-industry"] + }, + { + "id": "devnexus-2026-062", + "conference": "DevNexus 2026", + "title": "Developer Experience != Developer Productivity", + "speakers": ["Jeremy Meiss"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "15:50", + "start_datetime": "2026-03-05T15:50:00Z", + "session_type": "Talk", + "tags": ["developer-experience", "productivity", "tools"] + }, + { + "id": "devnexus-2026-063", + "conference": "DevNexus 2026", + "title": "The OffHeap Podcast. Devnexus Edition (Now with AI Agents)", + "speakers": ["Freddy Guime"], + "track": "Core Java", + "room": "301", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "16:50", + "start_datetime": "2026-03-05T16:50:00Z", + "session_type": "Talk", + "tags": ["java", "podcast", "ai", "agents", "community"] + }, + { + "id": "devnexus-2026-064", + "conference": "DevNexus 2026", + "title": "Conference Reception", + "speakers": [], + "track": "Social", + "room": "TBD", + "day": "Day 1", + "date": "2026-03-05", + "start_time": "17:30", + "start_datetime": "2026-03-05T17:30:00Z", + "session_type": "Social", + "tags": ["networking", "social"] + }, + { + "id": "devnexus-2026-065", + "conference": "DevNexus 2026", + "title": "Hacking AI - How to Survive the AI Uprising", + "speakers": ["Gant Laborde"], + "track": "Keynote", + "room": "411/412", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "09:00", + "start_datetime": "2026-03-06T09:00:00Z", + "session_type": "Keynote", + "tags": ["keynote", "ai", "security", "hacking"] + }, + { + "id": "devnexus-2026-066", + "conference": "DevNexus 2026", + "title": "Connecting the Dots with Context Graphs", + "speakers": ["Stephen Chin"], + "track": "AI Tools", + "room": "315", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "graphs", "context", "rag", "tools"] + }, + { + "id": "devnexus-2026-067", + "conference": "DevNexus 2026", + "title": "Spec Driven Development: Why Your Prompt Chaos Won't Scale", + "speakers": ["Simon Maple"], + "track": "AI in Practice", + "room": "311", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "llm", "prompting", "practical", "development"] + }, + { + "id": "devnexus-2026-068", + "conference": "DevNexus 2026", + "title": "Introduction to Cell Architectures", + "speakers": ["Christopher Curtin"], + "track": "Architecture", + "room": "314", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["architecture", "cell-architecture", "distributed-systems"] + }, + { + "id": "devnexus-2026-069", + "conference": "DevNexus 2026", + "title": "Scotty I Need Warp Speed - Ways to Improve JVM Startup", + "speakers": ["Gerrit Grunwald"], + "track": "Core Java", + "room": "301", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["java", "jvm", "startup", "performance", "graalvm"] + }, + { + "id": "devnexus-2026-070", + "conference": "DevNexus 2026", + "title": "API Versioning in Spring", + "speakers": ["Spencer Gibb"], + "track": "Frameworks", + "room": "313", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["spring", "api", "api-versioning", "frameworks"] + }, + { + "id": "devnexus-2026-071", + "conference": "DevNexus 2026", + "title": "OK, But What About Predictive AI?", + "speakers": ["Brayan Muñoz V.", "José R. Almonte C."], + "track": "Gen AI", + "room": "316", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "predictive-ai", "machine-learning", "generative-ai"] + }, + { + "id": "devnexus-2026-072", + "conference": "DevNexus 2026", + "title": "Maven's Hidden Secrets to Speed Up Your Build", + "speakers": ["Ko Turk"], + "track": "Practices", + "room": "302", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["maven", "build", "practices", "performance"] + }, + { + "id": "devnexus-2026-073", + "conference": "DevNexus 2026", + "title": "Spring Cloud Supercharged for Production-Ready Apps", + "speakers": ["Ryan Baxter", "Chris Sterling"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-cloud", "production", "cloud-native"] + }, + { + "id": "devnexus-2026-074", + "conference": "DevNexus 2026", + "title": "The Hidden Security Hazards in Your Java Stack", + "speakers": ["Brian Vermeer"], + "track": "Security", + "room": "304", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["java", "security", "vulnerabilities", "devsecops"] + }, + { + "id": "devnexus-2026-075", + "conference": "DevNexus 2026", + "title": "Back to the Future of Software: How to Survive the AI Apocalypse with Tests, Prompts, and Specs", + "speakers": ["Baruch Sadogursky", "Leonid Igolnik"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["ai", "testing", "leadership", "prompting", "software-engineering"] + }, + { + "id": "devnexus-2026-076", + "conference": "DevNexus 2026", + "title": "BoxLang vs the World: From Zero to Stable in 20 Months", + "speakers": ["Luis Majano", "Brad Wood"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "10:00", + "start_datetime": "2026-03-06T10:00:00Z", + "session_type": "Talk", + "tags": ["boxlang", "jvm-languages", "tools"] + }, + { + "id": "devnexus-2026-077", + "conference": "DevNexus 2026", + "title": "Performant GraphRAG: Improving Neo4j to Drive Autonomous AI", + "speakers": ["Brandon Tylke", "Jon Gentsch"], + "track": "AI Tools", + "room": "315", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["ai", "rag", "neo4j", "graphs", "tools", "llm"] + }, + { + "id": "devnexus-2026-078", + "conference": "DevNexus 2026", + "title": "From Microservices to Agent-Services: Architectural Patterns for Autonomous System Boundaries", + "speakers": ["Mo Haghighi", "Prasanth Kumar Pari"], + "track": "AI in Practice", + "room": "311", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "microservices", "architecture", "practical"] + }, + { + "id": "devnexus-2026-079", + "conference": "DevNexus 2026", + "title": "Breaking the Monolith Mindset: Hexagonal Architecture vs Traditional Layers in Java", + "speakers": ["Emmanuel Guzmán", "Jorge Cajas"], + "track": "Architecture", + "room": "314", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["architecture", "hexagonal", "java", "monolith", "layered-architecture"] + }, + { + "id": "devnexus-2026-080", + "conference": "DevNexus 2026", + "title": "Trash Talk - Exploring the Memory Management in the JVM", + "speakers": ["Gerrit Grunwald"], + "track": "Core Java", + "room": "301", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["java", "jvm", "memory-management", "garbage-collection", "performance"] + }, + { + "id": "devnexus-2026-081", + "conference": "DevNexus 2026", + "title": "Autoscaling Spring Boot Apps in Kubernetes with KEDA", + "speakers": ["John Coyne"], + "track": "Frameworks", + "room": "313", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-boot", "kubernetes", "keda", "autoscaling", "cloud-native"] + }, + { + "id": "devnexus-2026-082", + "conference": "DevNexus 2026", + "title": "Integrating LLMs in Java: A Practical Guide to Model Context Protocol", + "speakers": ["Dan Vega"], + "track": "Gen AI", + "room": "316", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["llm", "java", "mcp", "spring-ai", "generative-ai"] + }, + { + "id": "devnexus-2026-083", + "conference": "DevNexus 2026", + "title": "Sociotechnical Platform Engineering", + "speakers": ["Chris Corriere"], + "track": "Practices", + "room": "302", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["platform-engineering", "practices", "devops", "culture"] + }, + { + "id": "devnexus-2026-084", + "conference": "DevNexus 2026", + "title": "Refactoring the ROI of Software: Continuous Modernization with Tanzu Platform", + "speakers": ["DaShaun Carter"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["spring", "tanzu", "modernization", "refactoring", "cloud"] + }, + { + "id": "devnexus-2026-085", + "conference": "DevNexus 2026", + "title": "Implementing MCP Authorization Using Spring Security OAuth 2.1 Capabilities", + "speakers": ["Joe Grandja"], + "track": "Security", + "room": "304", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["mcp", "spring-security", "oauth2", "security", "authorization"] + }, + { + "id": "devnexus-2026-086", + "conference": "DevNexus 2026", + "title": "Delivering Value Through Software: A Practical Guide for Tech Leads", + "speakers": ["Sujith Paul"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["leadership", "career", "tech-lead", "management"] + }, + { + "id": "devnexus-2026-087", + "conference": "DevNexus 2026", + "title": "A Data-Oriented Programming Approach to REST APIs", + "speakers": ["Kenneth Kousen"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "11:20", + "start_datetime": "2026-03-06T11:20:00Z", + "session_type": "Talk", + "tags": ["java", "rest", "api", "data-oriented-programming", "tools"] + }, + { + "id": "devnexus-2026-088", + "conference": "DevNexus 2026", + "title": "Agentic AI for Java Microservices: Cloud Performance Optimization at FinTech Scale", + "speakers": ["Sibasis Padhi"], + "track": "AI Tools", + "room": "315", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "java", "microservices", "fintech", "cloud", "performance"] + }, + { + "id": "devnexus-2026-089", + "conference": "DevNexus 2026", + "title": "Conversational AI Semantic Search for E-commerce Using Elasticsearch + RAG", + "speakers": ["Karthik Govardhanan"], + "track": "AI in Practice", + "room": "311", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["ai", "rag", "semantic-search", "elasticsearch", "e-commerce"] + }, + { + "id": "devnexus-2026-090", + "conference": "DevNexus 2026", + "title": "Durable Execution: Building Apps That Refuse to Die", + "speakers": ["Sam Dengler"], + "track": "Architecture", + "room": "314", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["architecture", "resilience", "durable-execution", "reliability"] + }, + { + "id": "devnexus-2026-091", + "conference": "DevNexus 2026", + "title": "Just-in-Time Compilation Isn't Magic", + "speakers": ["Douglas Hawkins"], + "track": "Core Java", + "room": "301", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["java", "jvm", "jit", "performance", "compilation"] + }, + { + "id": "devnexus-2026-092", + "conference": "DevNexus 2026", + "title": "I Can See Clearly Now: Observability of JVM & Spring Boot 2-3-4 Apps", + "speakers": ["Jonatan Ivanov"], + "track": "Frameworks", + "room": "313", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["spring", "spring-boot", "observability", "monitoring", "jvm", "opentelemetry"] + }, + { + "id": "devnexus-2026-093", + "conference": "DevNexus 2026", + "title": "From Context Windows to Context Graphs: The Next Generation of AI Systems", + "speakers": ["Medha Chakraborty", "Srijani Dey"], + "track": "Gen AI", + "room": "316", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["ai", "graphs", "llm", "generative-ai", "context"] + }, + { + "id": "devnexus-2026-094", + "conference": "DevNexus 2026", + "title": "Better Assertions with AssertJ", + "speakers": ["Tim te Beek"], + "track": "Practices", + "room": "302", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["testing", "assertj", "java", "practices"] + }, + { + "id": "devnexus-2026-095", + "conference": "DevNexus 2026", + "title": "The Golden Path Starts at Home: Engineering Developer Experience from Laptop to Production", + "speakers": ["Tim Sparg", "DaShaun Carter"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["spring", "developer-experience", "production", "platform-engineering"] + }, + { + "id": "devnexus-2026-096", + "conference": "DevNexus 2026", + "title": "The Responsible Java Developer: Trustworthy GenAI in Practice", + "speakers": ["Brian Benz"], + "track": "Security", + "room": "304", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["java", "ai", "generative-ai", "security", "responsible-ai"] + }, + { + "id": "devnexus-2026-097", + "conference": "DevNexus 2026", + "title": "Beyond Code: Behavioral Architecture (Small Changes, Big Wins)", + "speakers": ["Sandeep Adinarayana"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["leadership", "architecture", "culture", "organizational-change"] + }, + { + "id": "devnexus-2026-098", + "conference": "DevNexus 2026", + "title": "Advancing with Java", + "speakers": ["Rodrigo Graciano", "Chandra Guntur"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "13:20", + "start_datetime": "2026-03-06T13:20:00Z", + "session_type": "Talk", + "tags": ["java", "career", "tools"] + }, + { + "id": "devnexus-2026-099", + "conference": "DevNexus 2026", + "title": "Stop Fighting Your AI: Engineering Prompts That Actually Work", + "speakers": ["Martin Rojas"], + "track": "AI Tools", + "room": "315", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["ai", "prompting", "prompt-engineering", "tools", "llm"] + }, + { + "id": "devnexus-2026-100", + "conference": "DevNexus 2026", + "title": "Real-Time Fraud Detection in Java with Kafka, Streams & Vector Similarity", + "speakers": ["Tim Kelly", "Ricardo Mello"], + "track": "AI in Practice", + "room": "311", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["java", "kafka", "streaming", "fraud-detection", "vector-search", "ai", "practical"] + }, + { + "id": "devnexus-2026-101", + "conference": "DevNexus 2026", + "title": "Isolation Isn't All Bad, For Your Database", + "speakers": ["Sean McNealy"], + "track": "Architecture", + "room": "314", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["database", "isolation", "architecture", "transactions"] + }, + { + "id": "devnexus-2026-102", + "conference": "DevNexus 2026", + "title": "Java's Asynchronous Ecosystem", + "speakers": ["Daniel Hinojosa"], + "track": "Core Java", + "room": "301", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["java", "async", "reactive", "virtual-threads", "jvm"] + }, + { + "id": "devnexus-2026-103", + "conference": "DevNexus 2026", + "title": "Extend Your JPA Applications with Relational JSON Documents", + "speakers": ["Anders Swanson"], + "track": "Frameworks", + "room": "313", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["java", "jpa", "json", "database", "spring", "frameworks"] + }, + { + "id": "devnexus-2026-104", + "conference": "DevNexus 2026", + "title": "Choose Your Fighter: Spring AI vs LangChain4j", + "speakers": ["Malavika Balamurali"], + "track": "Gen AI", + "room": "316", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["spring-ai", "langchain4j", "java", "generative-ai", "llm"] + }, + { + "id": "devnexus-2026-105", + "conference": "DevNexus 2026", + "title": "Grow Beyond Senior Through Java Contributions: JUGs, the JCP and OpenJDK", + "speakers": ["Bruno Souza", "Heather VanCura"], + "track": "Practices", + "room": "302", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["java", "open-source", "community", "jcp", "openjdk", "career"] + }, + { + "id": "devnexus-2026-106", + "conference": "DevNexus 2026", + "title": "Staying Current: A Journey in Software Maintenance", + "speakers": ["Shriyu Gaglani", "Ho Jong Yu"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["spring", "maintenance", "upgrades", "production"] + }, + { + "id": "devnexus-2026-107", + "conference": "DevNexus 2026", + "title": "Deep Dive Into Data Streaming Security", + "speakers": ["Olena Kutsenko"], + "track": "Security", + "room": "304", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["security", "kafka", "data-streaming", "encryption"] + }, + { + "id": "devnexus-2026-108", + "conference": "DevNexus 2026", + "title": "Engineering the Shift: How Technical Leaders Drive Enduring Change", + "speakers": ["Niranjan Prithviraj"], + "track": "Tech Leadership", + "room": "303", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["leadership", "management", "organizational-change", "tech-lead"] + }, + { + "id": "devnexus-2026-109", + "conference": "DevNexus 2026", + "title": "How I AI: Building Custom GPTs to Supercharge Everyday Work", + "speakers": ["Daneez Zamangil"], + "track": "Tools and Techniques", + "room": "305", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "14:40", + "start_datetime": "2026-03-06T14:40:00Z", + "session_type": "Talk", + "tags": ["ai", "gpt", "productivity", "tools", "automation"] + }, + { + "id": "devnexus-2026-110", + "conference": "DevNexus 2026", + "title": "AI Agents for Java Devs: From Demo to Deployment", + "speakers": ["Brian Benz"], + "track": "AI Tools", + "room": "315", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "15:40", + "start_datetime": "2026-03-06T15:40:00Z", + "session_type": "Talk", + "tags": ["ai", "agents", "java", "tools", "deployment"] + }, + { + "id": "devnexus-2026-111", + "conference": "DevNexus 2026", + "title": "Microservices for Pragmatists", + "speakers": ["Hazel Bohon"], + "track": "Architecture", + "room": "314", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "15:40", + "start_datetime": "2026-03-06T15:40:00Z", + "session_type": "Talk", + "tags": ["microservices", "architecture", "pragmatic"] + }, + { + "id": "devnexus-2026-112", + "conference": "DevNexus 2026", + "title": "Zero to C-Speed with Only Java", + "speakers": ["David Vlijmincx"], + "track": "Core Java", + "room": "301", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "15:40", + "start_datetime": "2026-03-06T15:40:00Z", + "session_type": "Talk", + "tags": ["java", "performance", "jvm", "native", "panama"] + }, + { + "id": "devnexus-2026-113", + "conference": "DevNexus 2026", + "title": "Debugging with IntelliJ IDEA", + "speakers": ["Anton Arhipov"], + "track": "Practices", + "room": "302", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "15:40", + "start_datetime": "2026-03-06T15:40:00Z", + "session_type": "Talk", + "tags": ["intellij", "debugging", "ide", "practices", "java"] + }, + { + "id": "devnexus-2026-114", + "conference": "DevNexus 2026", + "title": "Stop Getting Shift Left'ed On", + "speakers": ["DaShaun Carter", "Kevin Strohmeyer"], + "track": "Production Ready Spring", + "room": "312", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "15:40", + "start_datetime": "2026-03-06T15:40:00Z", + "session_type": "Talk", + "tags": ["spring", "security", "shift-left", "devops", "production"] + }, + { + "id": "devnexus-2026-115", + "conference": "DevNexus 2026", + "title": "Code Your Way to Quantum-Safe Development by Solving Tomorrow's Encryption Crisis", + "speakers": ["Barry Burd"], + "track": "Security", + "room": "304", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "15:40", + "start_datetime": "2026-03-06T15:40:00Z", + "session_type": "Talk", + "tags": ["security", "quantum", "encryption", "post-quantum", "cryptography"] + }, + { + "id": "devnexus-2026-116", + "conference": "DevNexus 2026", + "title": "Prize Giveaways and Conference Close!", + "speakers": [], + "track": "Social", + "room": "411/412", + "day": "Day 2", + "date": "2026-03-06", + "start_time": "16:40", + "start_datetime": "2026-03-06T16:40:00Z", + "session_type": "Closing", + "tags": ["closing", "social"] + } +] From e03e0ff1aaf0e33c4301125990b9948cb01a50ae Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 5 Mar 2026 22:38:03 -0500 Subject: [PATCH 09/13] fix(collection): validate collection name before API call; fix docs - Add isBlank() guard in createCollection() to throw IllegalArgumentException before calling SolrJ, ensuring consistent behavior regardless of SolrJ version - Update README: add create-collection to feature list, fix all tool names to kebab-case, split into Search/Indexing/Collections/Schema sections, list all three indexing tools separately Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- README.md | 33 +++++++++++++++---- .../server/collection/CollectionService.java | 4 +++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9dfc30c..488254e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Spring AI Model Context Protocol (MCP) server that provides tools for interact - 🔍 Search Solr collections with filtering, faceting, and pagination - 📝 Index documents in JSON, CSV, and XML +- 📁 Create collections with configurable shards, replicas, and configsets - 📊 Manage collections and view statistics - 🔧 Inspect schema - 🔌 Transports: STDIO (Claude Desktop) and HTTP (MCP Inspector) @@ -307,14 +308,34 @@ For complete setup instructions, see [docs/AUTH0_SETUP.md](docs/AUTH0_SETUP.md) ## Available MCP tools +### Search + +| Tool | Description | +|------|-------------| +| `search` | Full-text search with filtering, faceting, sorting, and pagination | + +### Indexing + +| Tool | Description | +|------|-------------| +| `index-json-documents` | Index documents from a JSON string into a Solr collection | +| `index-csv-documents` | Index documents from a CSV string into a Solr collection | +| `index-xml-documents` | Index documents from an XML string into a Solr collection | + +### Collections + +| Tool | Description | +|------|-------------| +| `create-collection` | Create a new Solr collection (configSet, numShards, replicationFactor optional — default to `_default`, `1`, `1`) | +| `list-collections` | List all available Solr collections | +| `get-collection-stats` | Get statistics and metrics for a collection | +| `check-health` | Check the health status of a collection | + +### Schema + | Tool | Description | |------|-------------| -| `search` | Search Solr collections with advanced query options | -| `index_documents` | Index documents from JSON, CSV, or XML | -| `listCollections` | List all available Solr collections | -| `getCollectionStats` | Get statistics and metrics for a collection | -| `checkHealth` | Check the health status of a collection | -| `getSchema` | Retrieve schema information for a collection | +| `get-schema` | Retrieve schema information for a collection | ## Available MCP Resources 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 39d7fff..9624800 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 @@ -1096,6 +1096,10 @@ public CollectionCreationResult createCollection( @McpToolParam(description = "Replication factor (SolrCloud only). Defaults to 1.", required = false) Integer replicationFactor) throws SolrServerException, IOException { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException(BLANK_COLLECTION_NAME_ERROR); + } + String effectiveConfigSet = configSet != null ? configSet : DEFAULT_CONFIGSET; int effectiveShards = numShards != null ? numShards : DEFAULT_NUM_SHARDS; int effectiveRf = replicationFactor != null ? replicationFactor : DEFAULT_REPLICATION_FACTOR; From 2815e8384c15a4a59ec3f16032cdf766cc3b5281 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 6 Mar 2026 20:29:42 -0500 Subject: [PATCH 10/13] feat(security): require authentication for create-collection tool Add @PreAuthorize("isAuthenticated()") to the createCollection method to prevent unauthorized collection creation via the MCP tool, consistent with the pattern used in SearchService and IndexingService. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: adityamparikh --- .../apache/solr/mcp/server/collection/CollectionService.java | 2 ++ 1 file changed, 2 insertions(+) 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 9624800..f189093 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 @@ -48,6 +48,7 @@ import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; /** @@ -1087,6 +1088,7 @@ public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection * @throws IOException * if there are I/O errors during communication */ + @PreAuthorize("isAuthenticated()") @McpTool(name = "create-collection", description = "Create a new Solr collection. " + "configSet defaults to _default, numShards and replicationFactor default to 1.") public CollectionCreationResult createCollection( From 494000208137514b8614d378c6ec408d3889ec97 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 8 Mar 2026 20:00:19 -0400 Subject: [PATCH 11/13] fix(quality): resolve SonarQube violations in solr-10 - Remove unnecessary local variable in validateCollectionExists (S1488) - Add private constructor to CollectionUtils utility class (S1118) Signed-off-by: Aditya Parikh Co-Authored-By: Claude Opus 4.6 Signed-off-by: adityamparikh --- .../apache/solr/mcp/server/collection/CollectionService.java | 4 +--- .../apache/solr/mcp/server/collection/CollectionUtils.java | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) 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 f189093..ea52714 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 @@ -982,9 +982,7 @@ private boolean validateCollectionExists(String collection) { // Check if any of the returned collections start with the collection name (for // shard // names) - boolean shardMatch = collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX)); - - return shardMatch; + return collections.stream().anyMatch(c -> c.startsWith(collection + SHARD_SUFFIX)); } catch (Exception e) { return false; } 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 9e5ea92..d9fe22a 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 @@ -65,6 +65,10 @@ */ public class CollectionUtils { + private CollectionUtils() { + // Utility class — prevent instantiation + } + /** * Extracts a Long value from a NamedList using the specified key with robust * type conversion. From dbb53e8f48d127d9aad005559db30d42374461c2 Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Tue, 24 Mar 2026 14:24:07 -0400 Subject: [PATCH 12/13] Prevent instantiation of CollectionUtils class --- .../org/apache/solr/mcp/server/collection/CollectionUtils.java | 3 --- 1 file changed, 3 deletions(-) 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 5f4ae6d..dbceda9 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 @@ -66,10 +66,7 @@ public class CollectionUtils { private CollectionUtils() { -<<<<<<< HEAD // Utility class — prevent instantiation -======= ->>>>>>> upstream/main } /** From 5fb74160e8d5209c76eeef564e3592a3d8cf8250 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Wed, 25 Mar 2026 21:16:03 -0400 Subject: [PATCH 13/13] refactor(collection): migrate metrics from /admin/mbeans to /admin/metrics API Replace the deprecated /admin/mbeans endpoint with the Solr Metrics API (/admin/metrics) for cache and handler statistics. The Metrics API is node-level, so fetchMetrics now searches for the matching core registry by prefix. Cache metrics remain nested NamedList objects while handler metrics use flat dotted keys (e.g. QUERY./select.requests), requiring separate extraction logic. Key changes: - fetchMetrics uses /admin/metrics with group=core and prefix filtering - Handler extraction refactored to read flat keys via extractFlatHandlerInfo - Core registry matching uses trailing dot to prevent false prefix matches - Remove unused AVG_TIME_PER_REQUEST_FIELD and AVG_REQUESTS_PER_SECOND_FIELD - Add integration tests for cache and handler metrics against real Solr - Remove dead test helper createCompleteMockCacheData Co-Authored-By: Claude Opus 4.6 Signed-off-by: adityamparikh --- .../server/collection/CollectionService.java | 369 +++++++----------- .../CollectionServiceIntegrationTest.java | 48 ++- .../collection/CollectionServiceTest.java | 284 +++++++------- 3 files changed, 326 insertions(+), 375 deletions(-) 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 5bbb1f1..c633a17 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 @@ -133,36 +133,39 @@ public class CollectionService { // Constants for API Parameters and Paths // ======================================== - /** Category parameter value for cache-related MBeans requests */ - private static final String CACHE_CATEGORY = "CACHE"; - - /** Category parameter value for query handler MBeans requests */ - private static final String QUERY_HANDLER_CATEGORY = "QUERYHANDLER"; - - /** - * Combined category parameter value for both query and update handler MBeans - * requests - */ - private static final String HANDLER_CATEGORIES = "QUERYHANDLER,UPDATEHANDLER"; - /** Universal Solr query pattern to match all documents in a collection */ private static final String ALL_DOCUMENTS_QUERY = "*:*"; /** Suffix pattern used to identify shard names in SolrCloud deployments */ private static final String SHARD_SUFFIX = "_shard"; - /** Request parameter name for enabling statistics in MBeans requests */ - private static final String STATS_PARAM = "stats"; - - /** Request parameter name for specifying category filters in MBeans requests */ - private static final String CAT_PARAM = "cat"; - /** Request parameter name for specifying response writer type */ private static final String WT_PARAM = "wt"; /** JSON format specification for response writer type */ private static final String JSON_FORMAT = "json"; + /** URL path for Solr Metrics admin endpoint */ + private static final String ADMIN_METRICS_PATH = "/admin/metrics"; + + /** Request parameter name for specifying the metrics group */ + private static final String GROUP_PARAM = "group"; + + /** Request parameter name for filtering metrics by key prefix */ + private static final String PREFIX_PARAM = "prefix"; + + /** Metrics group for core-level metrics */ + private static final String CORE_GROUP = "core"; + + /** Prefix for cache metrics in the Metrics API */ + private static final String CACHE_METRIC_PREFIX = "CACHE.searcher"; + + /** Prefix for select handler metrics in the Metrics API */ + private static final String SELECT_HANDLER_METRIC_PREFIX = "QUERY./select"; + + /** Prefix for update handler metrics in the Metrics API */ + private static final String UPDATE_HANDLER_METRIC_PREFIX = "UPDATE./update"; + // ======================================== // Constants for Response Parsing // ======================================== @@ -173,30 +176,23 @@ public class CollectionService { /** Key name for segment count information in Luke response */ private static final String SEGMENT_COUNT_KEY = "segmentCount"; - /** Key name for query result cache in MBeans cache responses */ - private static final String QUERY_RESULT_CACHE_KEY = "queryResultCache"; + /** Top-level key in Metrics API responses */ + private static final String METRICS_KEY = "metrics"; - /** Key name for document cache in MBeans cache responses */ - private static final String DOCUMENT_CACHE_KEY = "documentCache"; + /** Metrics API key for query result cache */ + private static final String QUERY_RESULT_CACHE_KEY = "CACHE.searcher.queryResultCache"; - /** Key name for filter cache in MBeans cache responses */ - private static final String FILTER_CACHE_KEY = "filterCache"; + /** Metrics API key for document cache */ + private static final String DOCUMENT_CACHE_KEY = "CACHE.searcher.documentCache"; - /** Key name for statistics section in MBeans responses */ - private static final String STATS_KEY = "stats"; - - // ======================================== - // Constants for Handler Paths - // ======================================== + /** Metrics API key for filter cache */ + private static final String FILTER_CACHE_KEY = "CACHE.searcher.filterCache"; - /** URL path for Solr select (query) handler */ - private static final String SELECT_HANDLER_PATH = "/select"; + /** Flat metric key prefix for select handler stats */ + private static final String SELECT_HANDLER_KEY = "QUERY./select."; - /** URL path for Solr update handler */ - private static final String UPDATE_HANDLER_PATH = "/update"; - - /** URL path for Solr MBeans admin endpoint */ - private static final String ADMIN_MBEANS_PATH = "/admin/mbeans"; + /** Flat metric key prefix for update handler stats */ + private static final String UPDATE_HANDLER_KEY = "UPDATE./update."; // ======================================== // Constants for Statistics Field Names @@ -232,12 +228,6 @@ public class CollectionService { /** Field name for handler total processing time statistics */ private static final String TOTAL_TIME_FIELD = "totalTime"; - /** Field name for handler average time per request statistics */ - private static final String AVG_TIME_PER_REQUEST_FIELD = "avgTimePerRequest"; - - /** Field name for handler average requests per second statistics */ - private static final String AVG_REQUESTS_PER_SECOND_FIELD = "avgRequestsPerSecond"; - // ======================================== // Constants for Error Messages // ======================================== @@ -525,7 +515,7 @@ public QueryStats buildQueryStats(QueryResponse response) { * Retrieves cache performance metrics for all cache types in a Solr collection. * *

- * Collects detailed cache utilization statistics from Solr's MBeans endpoint, + * Collects detailed cache utilization statistics from Solr's Metrics API, * providing insights into cache effectiveness and memory usage patterns. Cache * performance directly impacts query response times and system efficiency. * @@ -568,37 +558,21 @@ public QueryStats buildQueryStats(QueryResponse response) { */ public CacheStats getCacheMetrics(String collection) { try { - // Get MBeans for cache information - ModifiableSolrParams params = new ModifiableSolrParams(); - params.set(STATS_PARAM, "true"); - params.set(CAT_PARAM, CACHE_CATEGORY); - params.set(WT_PARAM, JSON_FORMAT); - - // Extract actual collection name from shard name if needed String actualCollection = extractCollectionName(collection); - // Validate collection exists first if (!validateCollectionExists(actualCollection)) { - return null; // Return null instead of empty object + return null; } - String path = "/" + actualCollection + ADMIN_MBEANS_PATH; - - GenericSolrRequest request = new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); - - NamedList response = solrClient.request(request); - CacheStats stats = extractCacheStats(response); - - // Return null if all cache stats are empty/null - if (isCacheStatsEmpty(stats)) { + NamedList coreMetrics = fetchMetrics(actualCollection, CACHE_METRIC_PREFIX); + if (coreMetrics == null) { return null; } - return stats; + CacheStats stats = extractCacheStats(coreMetrics); + return isCacheStatsEmpty(stats) ? null : stats; } catch (SolrServerException | IOException | RuntimeException _) { - // RuntimeException covers SolrException subclasses (e.g. RemoteSolrException) - // thrown when the /admin/mbeans endpoint is unavailable (removed in Solr 10). - return null; // Return null instead of empty object + return null; } } @@ -620,81 +594,26 @@ private boolean isCacheStatsEmpty(CacheStats stats) { } /** - * Extracts cache performance statistics from Solr MBeans response data. - * - *

- * Parses the raw MBeans response to extract structured cache performance - * metrics for all available cache types. Each cache type provides detailed - * statistics including hit ratios, eviction rates, and current utilization. - * - *

- * Parsed Cache Types: - * - *

    - *
  • queryResultCache - Complete query result caching - *
  • documentCache - Retrieved document data caching - *
  • filterCache - Filter query result caching - *
- * - *

- * For each cache type, the following metrics are extracted: + * Extracts cache performance statistics from Solr Metrics API response data. * - *

    - *
  • lookups, hits, hitratio - Performance effectiveness - *
  • inserts, evictions - Memory management patterns - *
  • size - Current utilization - *
- * - * @param mbeans - * the raw MBeans response from Solr admin endpoint + * @param coreMetrics + * the core metrics from the Solr Metrics API * @return CacheStats object containing parsed metrics for all cache types - * @see CacheStats - * @see CacheInfo */ - private CacheStats extractCacheStats(NamedList mbeans) { - CacheInfo queryResultCacheInfo = null; - CacheInfo documentCacheInfo = null; - CacheInfo filterCacheInfo = null; - - @SuppressWarnings("unchecked") - NamedList caches = (NamedList) mbeans.get(CACHE_CATEGORY); - - if (caches != null) { - // Query result cache - @SuppressWarnings("unchecked") - NamedList queryResultCache = (NamedList) caches.get(QUERY_RESULT_CACHE_KEY); - if (queryResultCache != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) queryResultCache.get(STATS_KEY); - queryResultCacheInfo = new CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD)); - } - - // Document cache - @SuppressWarnings("unchecked") - NamedList documentCache = (NamedList) caches.get(DOCUMENT_CACHE_KEY); - if (documentCache != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) documentCache.get(STATS_KEY); - documentCacheInfo = new CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD)); - } + private CacheStats extractCacheStats(NamedList coreMetrics) { + return new CacheStats(extractSingleCacheInfo(coreMetrics, QUERY_RESULT_CACHE_KEY), + extractSingleCacheInfo(coreMetrics, DOCUMENT_CACHE_KEY), + extractSingleCacheInfo(coreMetrics, FILTER_CACHE_KEY)); + } - // Filter cache - @SuppressWarnings("unchecked") - NamedList filterCache = (NamedList) caches.get(FILTER_CACHE_KEY); - if (filterCache != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) filterCache.get(STATS_KEY); - filterCacheInfo = new CacheInfo(getLong(stats, LOOKUPS_FIELD), getLong(stats, HITS_FIELD), - getFloat(stats, HITRATIO_FIELD), getLong(stats, INSERTS_FIELD), getLong(stats, EVICTIONS_FIELD), - getLong(stats, SIZE_FIELD)); - } + @SuppressWarnings("unchecked") + private CacheInfo extractSingleCacheInfo(NamedList coreMetrics, String key) { + NamedList cache = (NamedList) coreMetrics.get(key); + if (cache == null) { + return null; } - - return new CacheStats(queryResultCacheInfo, documentCacheInfo, filterCacheInfo); + return new CacheInfo(getLong(cache, LOOKUPS_FIELD), getLong(cache, HITS_FIELD), getFloat(cache, HITRATIO_FIELD), + getLong(cache, INSERTS_FIELD), getLong(cache, EVICTIONS_FIELD), getLong(cache, SIZE_FIELD)); } /** @@ -709,10 +628,10 @@ private CacheStats extractCacheStats(NamedList mbeans) { * Monitored Handlers: * *
    - *
  • Select Handler ({@value #SELECT_HANDLER_PATH}): - * Processes search and query requests - *
  • Update Handler ({@value #UPDATE_HANDLER_PATH}): - * Processes document indexing operations + *
  • Select Handler (/select): Processes search and query + * requests + *
  • Update Handler (/update): Processes document indexing + * operations *
* *

@@ -738,41 +657,28 @@ private CacheStats extractCacheStats(NamedList mbeans) { * null if unavailable * @see HandlerStats * @see HandlerInfo - * @see #extractHandlerStats(NamedList) + * @see #fetchFlatHandlerInfo(String, String, String) * @see #isHandlerStatsEmpty(HandlerStats) */ public HandlerStats getHandlerMetrics(String collection) { try { - ModifiableSolrParams params = new ModifiableSolrParams(); - params.set(STATS_PARAM, "true"); - params.set(CAT_PARAM, HANDLER_CATEGORIES); - params.set(WT_PARAM, JSON_FORMAT); - - // Extract actual collection name from shard name if needed String actualCollection = extractCollectionName(collection); - // Validate collection exists first if (!validateCollectionExists(actualCollection)) { - return null; // Return null instead of empty object - } - - String path = "/" + actualCollection + ADMIN_MBEANS_PATH; - - GenericSolrRequest request = new GenericSolrRequest(SolrRequest.METHOD.GET, path, params); - - NamedList response = solrClient.request(request); - HandlerStats stats = extractHandlerStats(response); - - // Return null if all handler stats are empty/null - if (isHandlerStatsEmpty(stats)) { return null; } - return stats; + // Handler metrics are flat keys (e.g. QUERY./select.requests) so we + // fetch each handler prefix separately and reconstruct HandlerInfo + HandlerInfo selectHandler = fetchFlatHandlerInfo(actualCollection, SELECT_HANDLER_METRIC_PREFIX, + SELECT_HANDLER_KEY); + HandlerInfo updateHandler = fetchFlatHandlerInfo(actualCollection, UPDATE_HANDLER_METRIC_PREFIX, + UPDATE_HANDLER_KEY); + + HandlerStats stats = new HandlerStats(selectHandler, updateHandler); + return isHandlerStatsEmpty(stats) ? null : stats; } catch (SolrServerException | IOException | RuntimeException _) { - // RuntimeException covers SolrException subclasses (e.g. RemoteSolrException) - // thrown when the /admin/mbeans endpoint is unavailable (removed in Solr 10). - return null; // Return null instead of empty object + return null; } } @@ -793,69 +699,92 @@ private boolean isHandlerStatsEmpty(HandlerStats stats) { } /** - * Extracts request handler performance statistics from Solr MBeans response - * data. - * - *

- * Parses the raw MBeans response to extract structured handler performance - * metrics for query and update operations. Each handler provides detailed - * statistics about request processing including volume, errors, and timing. - * - *

- * Parsed Handler Types: - * - *

    - *
  • /select - Search and query request handler - *
  • /update - Document indexing request handler - *
- * - *

- * For each handler type, the following metrics are extracted: - * - *

    - *
  • requests, errors, timeouts - Volume and reliability - *
  • totalTime, avgTimePerRequest - Performance characteristics - *
  • avgRequestsPerSecond - Throughput capacity - *
+ * Fetches metrics from the Solr Metrics API for a given collection and prefix. * - * @param mbeans - * the raw MBeans response from Solr admin endpoint - * @return HandlerStats object containing parsed metrics for all handler types - * @see HandlerStats - * @see HandlerInfo + * @param collection + * the collection name + * @param prefix + * the metric key prefix to filter (e.g. "CACHE.searcher", "HANDLER") + * @return the core-level metrics NamedList, or null if unavailable */ - private HandlerStats extractHandlerStats(NamedList mbeans) { - HandlerInfo selectHandlerInfo = null; - HandlerInfo updateHandlerInfo = null; - - @SuppressWarnings("unchecked") - NamedList queryHandlers = (NamedList) mbeans.get(QUERY_HANDLER_CATEGORY); + @SuppressWarnings("unchecked") + private NamedList fetchMetrics(String collection, String prefix) throws SolrServerException, IOException { + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set(GROUP_PARAM, CORE_GROUP); + params.set(PREFIX_PARAM, prefix); + params.set(WT_PARAM, JSON_FORMAT); + + // Metrics API is a node-level endpoint, not per-collection + GenericSolrRequest request = new GenericSolrRequest(SolrRequest.METHOD.GET, ADMIN_METRICS_PATH, params); + + NamedList response = solrClient.request(request); + NamedList metrics = (NamedList) response.get(METRICS_KEY); + if (metrics == null || metrics.size() == 0) { + return null; + } - if (queryHandlers != null) { - // Select handler - @SuppressWarnings("unchecked") - NamedList selectHandler = (NamedList) queryHandlers.get(SELECT_HANDLER_PATH); - if (selectHandler != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) selectHandler.get(STATS_KEY); - selectHandlerInfo = new HandlerInfo(getLong(stats, REQUESTS_FIELD), getLong(stats, ERRORS_FIELD), - getLong(stats, TIMEOUTS_FIELD), getLong(stats, TOTAL_TIME_FIELD), - getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); + // Find the core registry matching the requested collection + // Keys are like "solr.core..." + String corePrefix = "solr.core." + collection + "."; + for (int i = 0; i < metrics.size(); i++) { + String key = metrics.getName(i); + if (key != null && key.startsWith(corePrefix)) { + return (NamedList) metrics.getVal(i); } + } + return null; + } - // Update handler - @SuppressWarnings("unchecked") - NamedList updateHandler = (NamedList) queryHandlers.get(UPDATE_HANDLER_PATH); - if (updateHandler != null) { - @SuppressWarnings("unchecked") - NamedList stats = (NamedList) updateHandler.get(STATS_KEY); - updateHandlerInfo = new HandlerInfo(getLong(stats, REQUESTS_FIELD), getLong(stats, ERRORS_FIELD), - getLong(stats, TIMEOUTS_FIELD), getLong(stats, TOTAL_TIME_FIELD), - getFloat(stats, AVG_TIME_PER_REQUEST_FIELD), getFloat(stats, AVG_REQUESTS_PER_SECOND_FIELD)); - } + /** + * Fetches and extracts handler metrics from flat Solr Metrics API keys. + * + *

+ * Handler metrics in Solr are stored as flat keys (e.g. + * {@code QUERY./select.requests}) rather than nested objects. This method + * fetches core metrics filtered by the handler prefix and reconstructs a + * {@link HandlerInfo} from the individual flat keys. + * + * @param collection + * the collection name + * @param metricPrefix + * the prefix for the Metrics API filter (e.g. {@code QUERY./select}) + * @param keyPrefix + * the flat key prefix including trailing dot (e.g. + * {@code QUERY./select.}) + * @return HandlerInfo with stats, or null if unavailable + */ + private HandlerInfo fetchFlatHandlerInfo(String collection, String metricPrefix, String keyPrefix) + throws SolrServerException, IOException { + NamedList coreMetrics = fetchMetrics(collection, metricPrefix); + if (coreMetrics == null) { + return null; } + return extractFlatHandlerInfo(coreMetrics, keyPrefix); + } - return new HandlerStats(selectHandlerInfo, updateHandlerInfo); + /** + * Extracts a {@link HandlerInfo} from flat metric keys in core metrics. + * + * @param coreMetrics + * the core metrics NamedList with flat keys + * @param keyPrefix + * the flat key prefix including trailing dot (e.g. + * {@code QUERY./select.}) + * @return HandlerInfo reconstructed from flat keys, or null if no requests key + * found + */ + private HandlerInfo extractFlatHandlerInfo(NamedList coreMetrics, String keyPrefix) { + Long requests = getLong(coreMetrics, keyPrefix + REQUESTS_FIELD); + if (requests == null) { + return null; + } + Long errors = getLong(coreMetrics, keyPrefix + ERRORS_FIELD); + Long timeouts = getLong(coreMetrics, keyPrefix + TIMEOUTS_FIELD); + Long totalTime = getLong(coreMetrics, keyPrefix + TOTAL_TIME_FIELD); + // avgTimePerRequest and avgRequestsPerSecond are not available as flat metrics; + // compute avgTimePerRequest from totalTime/requests when possible + Float avgTimePerRequest = (requests > 0 && totalTime != null) ? (float) totalTime / requests : null; + return new HandlerInfo(requests, errors, timeouts, totalTime, avgTimePerRequest, null); } /** diff --git a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java index c520bd0..6141d0e 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceIntegrationTest.java @@ -177,10 +177,8 @@ void testCheckHealthHealthy() { // Verify string representation contains meaningful information String statusString = status.toString(); - if (statusString != null) { - assertTrue(statusString.contains("healthy") || statusString.contains("true"), - "Status string should indicate healthy state"); - } + assertTrue(statusString.contains("healthy") || statusString.contains("true"), + "Status string should indicate healthy state"); } @Test @@ -244,6 +242,48 @@ void testCollectionNameExtraction() { assertEquals("", collectionService.extractCollectionName(""), "Empty string should return empty string"); } + @Test + void testGetCacheMetrics() { + CacheStats cacheStats = collectionService.getCacheMetrics(TEST_COLLECTION); + + assertNotNull(cacheStats, "Cache stats should not be null for an existing collection"); + + // Solr registers all three caches at collection creation + assertNotNull(cacheStats.queryResultCache(), "Query result cache should not be null"); + assertTrue(cacheStats.queryResultCache().lookups() >= 0, "Query result cache lookups should be non-negative"); + + assertNotNull(cacheStats.documentCache(), "Document cache should not be null"); + assertTrue(cacheStats.documentCache().lookups() >= 0, "Document cache lookups should be non-negative"); + + assertNotNull(cacheStats.filterCache(), "Filter cache should not be null"); + assertTrue(cacheStats.filterCache().lookups() >= 0, "Filter cache lookups should be non-negative"); + } + + @Test + void testGetHandlerMetrics() { + HandlerStats handlerStats = collectionService.getHandlerMetrics(TEST_COLLECTION); + + assertNotNull(handlerStats, "Handler stats should not be null for an existing collection"); + + assertNotNull(handlerStats.selectHandler(), "Select handler should not be null"); + assertTrue(handlerStats.selectHandler().requests() >= 0, "Select handler requests should be non-negative"); + + assertNotNull(handlerStats.updateHandler(), "Update handler should not be null"); + assertTrue(handlerStats.updateHandler().requests() >= 0, "Update handler requests should be non-negative"); + } + + @Test + void testGetCacheMetrics_NonExistentCollection() { + CacheStats result = collectionService.getCacheMetrics("non_existent_collection"); + assertNull(result, "Cache metrics for non-existent collection should be null"); + } + + @Test + void testGetHandlerMetrics_NonExistentCollection() { + HandlerStats result = collectionService.getHandlerMetrics("non_existent_collection"); + assertNull(result, "Handler metrics for non-existent collection should be null"); + } + @Test void createCollection_createsAndListable() throws Exception { String name = "mcp_test_create_" + System.currentTimeMillis(); diff --git a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java index 4f72424..b28b2ce 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java @@ -460,9 +460,9 @@ void getCacheMetrics_EmptyStats() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - NamedList mbeans = new NamedList<>(); - mbeans.add("CACHE", new NamedList<>()); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + // Metrics response with core metrics that contain no cache keys + NamedList response = wrapInMetricsResponse(new NamedList<>()); + when(solrClient.request(any(SolrRequest.class))).thenReturn(response); CacheStats result = spyService.getCacheMetrics("test_collection"); @@ -474,8 +474,8 @@ void getCacheMetrics_WithShardName() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections(); - NamedList mbeans = createMockCacheData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + NamedList response = wrapInMetricsResponse(createCacheCoreMetrics(), "films"); + when(solrClient.request(any(SolrRequest.class))).thenReturn(response); CacheStats result = spyService.getCacheMetrics("films_shard1_replica_n1"); @@ -484,11 +484,11 @@ void getCacheMetrics_WithShardName() throws Exception { @Test void extractCacheStats() throws Exception { - NamedList mbeans = createMockCacheData(); + NamedList coreMetrics = createCacheCoreMetrics(); Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); method.setAccessible(true); - CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); + CacheStats result = (CacheStats) method.invoke(collectionService, coreMetrics); assertNotNull(result.queryResultCache()); assertEquals(100L, result.queryResultCache().lookups()); @@ -497,11 +497,11 @@ void extractCacheStats() throws Exception { @Test void extractCacheStats_AllCacheTypes() throws Exception { - NamedList mbeans = createCompleteMockCacheData(); + NamedList coreMetrics = createCompleteCacheCoreMetrics(); Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); method.setAccessible(true); - CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); + CacheStats result = (CacheStats) method.invoke(collectionService, coreMetrics); assertNotNull(result.queryResultCache()); assertNotNull(result.documentCache()); @@ -509,14 +509,14 @@ void extractCacheStats_AllCacheTypes() throws Exception { } @Test - void extractCacheStats_NullCacheCategory() throws Exception { - NamedList mbeans = new NamedList<>(); - mbeans.add("CACHE", null); + void extractCacheStats_NoCacheKeys() throws Exception { + // Core metrics with no cache keys at all + NamedList coreMetrics = new NamedList<>(); Method method = CollectionService.class.getDeclaredMethod("extractCacheStats", NamedList.class); method.setAccessible(true); - CacheStats result = (CacheStats) method.invoke(collectionService, mbeans); + CacheStats result = (CacheStats) method.invoke(collectionService, coreMetrics); assertNotNull(result); assertNull(result.queryResultCache()); @@ -552,13 +552,15 @@ void getHandlerMetrics_Success() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - NamedList mbeans = createMockHandlerData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + // getHandlerMetrics makes two fetchMetrics calls (select then update); + // return select handler data for both calls (second has no update keys -> null) + when(solrClient.request(any(SolrRequest.class))).thenReturn(createMockSelectHandlerData()); HandlerStats result = spyService.getHandlerMetrics("test_collection"); assertNotNull(result); assertNotNull(result.selectHandler()); + assertEquals(500L, result.selectHandler().requests()); } @Test @@ -600,9 +602,9 @@ void getHandlerMetrics_EmptyStats() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("test_collection")).when(spyService).listCollections(); - NamedList mbeans = new NamedList<>(); - mbeans.add("QUERYHANDLER", new NamedList<>()); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + // Metrics response with core metrics that contain no handler keys + NamedList response = wrapInMetricsResponse(new NamedList<>()); + when(solrClient.request(any(SolrRequest.class))).thenReturn(response); HandlerStats result = spyService.getHandlerMetrics("test_collection"); @@ -614,8 +616,7 @@ void getHandlerMetrics_WithShardName() throws Exception { CollectionService spyService = spy(collectionService); doReturn(Arrays.asList("films_shard1_replica_n1")).when(spyService).listCollections(); - NamedList mbeans = createMockHandlerData(); - when(solrClient.request(any(SolrRequest.class))).thenReturn(mbeans); + when(solrClient.request(any(SolrRequest.class))).thenReturn(createMockSelectHandlerData("films")); HandlerStats result = spyService.getHandlerMetrics("films_shard1_replica_n1"); @@ -623,44 +624,47 @@ void getHandlerMetrics_WithShardName() throws Exception { } @Test - void extractHandlerStats() throws Exception { - NamedList mbeans = createMockHandlerData(); - Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + void extractFlatHandlerInfo_SelectHandler() throws Exception { + NamedList coreMetrics = createSelectHandlerCoreMetrics(); + Method method = CollectionService.class.getDeclaredMethod("extractFlatHandlerInfo", NamedList.class, + String.class); method.setAccessible(true); - HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); + HandlerInfo result = (HandlerInfo) method.invoke(collectionService, coreMetrics, "QUERY./select."); - assertNotNull(result.selectHandler()); - assertEquals(500L, result.selectHandler().requests()); + assertNotNull(result); + assertEquals(500L, result.requests()); + assertEquals(5L, result.errors()); + assertEquals(2L, result.timeouts()); + assertEquals(10000L, result.totalTime()); + // avgTimePerRequest computed: 10000/500 = 20.0 + assertEquals(20.0f, result.avgTimePerRequest()); } @Test - void extractHandlerStats_BothHandlers() throws Exception { - NamedList mbeans = createCompleteHandlerData(); - Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + void extractFlatHandlerInfo_UpdateHandler() throws Exception { + NamedList coreMetrics = createUpdateHandlerCoreMetrics(); + Method method = CollectionService.class.getDeclaredMethod("extractFlatHandlerInfo", NamedList.class, + String.class); method.setAccessible(true); - HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); + HandlerInfo result = (HandlerInfo) method.invoke(collectionService, coreMetrics, "UPDATE./update."); - assertNotNull(result.selectHandler()); - assertNotNull(result.updateHandler()); - assertEquals(500L, result.selectHandler().requests()); - assertEquals(250L, result.updateHandler().requests()); + assertNotNull(result); + assertEquals(250L, result.requests()); + assertEquals(2L, result.errors()); } @Test - void extractHandlerStats_NullHandlerCategory() throws Exception { - NamedList mbeans = new NamedList<>(); - mbeans.add("QUERYHANDLER", null); - - Method method = CollectionService.class.getDeclaredMethod("extractHandlerStats", NamedList.class); + void extractFlatHandlerInfo_NoHandlerKeys() throws Exception { + NamedList coreMetrics = new NamedList<>(); + Method method = CollectionService.class.getDeclaredMethod("extractFlatHandlerInfo", NamedList.class, + String.class); method.setAccessible(true); - HandlerStats result = (HandlerStats) method.invoke(collectionService, mbeans); + HandlerInfo result = (HandlerInfo) method.invoke(collectionService, coreMetrics, "QUERY./select."); - assertNotNull(result); - assertNull(result.selectHandler()); - assertNull(result.updateHandler()); + assertNull(result); } @Test @@ -725,121 +729,99 @@ void listCollections_IOError() throws Exception { assertTrue(result.isEmpty()); } - // Helper methods - private NamedList createMockCacheData() { - NamedList mbeans = new NamedList<>(); - NamedList cacheCategory = new NamedList<>(); - NamedList queryResultCache = new NamedList<>(); - NamedList queryStats = new NamedList<>(); + // Helper methods — mock the Solr Metrics API response format: + // response -> "metrics" -> "solr.core." -> "CACHE.searcher.xxx" / + // "HANDLER./xxx" + + // Core metrics builders (unwrapped — used by reflection tests for extract* + // methods) + private NamedList createCacheCoreMetrics() { + NamedList coreMetrics = new NamedList<>(); - queryStats.add("lookups", 100L); - queryStats.add("hits", 80L); - queryStats.add("hitratio", 0.8f); - queryStats.add("inserts", 20L); - queryStats.add("evictions", 5L); - queryStats.add("size", 100L); - queryResultCache.add("stats", queryStats); - cacheCategory.add("queryResultCache", queryResultCache); - mbeans.add("CACHE", cacheCategory); + NamedList queryResultCache = new NamedList<>(); + queryResultCache.add("lookups", 100L); + queryResultCache.add("hits", 80L); + queryResultCache.add("hitratio", 0.8f); + queryResultCache.add("inserts", 20L); + queryResultCache.add("evictions", 5L); + queryResultCache.add("size", 100L); + coreMetrics.add("CACHE.searcher.queryResultCache", queryResultCache); - return mbeans; + return coreMetrics; } - private NamedList createCompleteMockCacheData() { - NamedList mbeans = new NamedList<>(); - NamedList cacheCategory = new NamedList<>(); + private NamedList createCompleteCacheCoreMetrics() { + NamedList coreMetrics = createCacheCoreMetrics(); - // Query Result Cache - NamedList queryResultCache = new NamedList<>(); - NamedList queryStats = new NamedList<>(); - queryStats.add("lookups", 100L); - queryStats.add("hits", 80L); - queryStats.add("hitratio", 0.8f); - queryStats.add("inserts", 20L); - queryStats.add("evictions", 5L); - queryStats.add("size", 100L); - queryResultCache.add("stats", queryStats); - - // Document Cache NamedList documentCache = new NamedList<>(); - NamedList docStats = new NamedList<>(); - docStats.add("lookups", 200L); - docStats.add("hits", 150L); - docStats.add("hitratio", 0.75f); - docStats.add("inserts", 50L); - docStats.add("evictions", 10L); - docStats.add("size", 180L); - documentCache.add("stats", docStats); - - // Filter Cache + documentCache.add("lookups", 200L); + documentCache.add("hits", 150L); + documentCache.add("hitratio", 0.75f); + documentCache.add("inserts", 50L); + documentCache.add("evictions", 10L); + documentCache.add("size", 180L); + coreMetrics.add("CACHE.searcher.documentCache", documentCache); + NamedList filterCache = new NamedList<>(); - NamedList filterStats = new NamedList<>(); - filterStats.add("lookups", 150L); - filterStats.add("hits", 120L); - filterStats.add("hitratio", 0.8f); - filterStats.add("inserts", 30L); - filterStats.add("evictions", 8L); - filterStats.add("size", 140L); - filterCache.add("stats", filterStats); - - cacheCategory.add("queryResultCache", queryResultCache); - cacheCategory.add("documentCache", documentCache); - cacheCategory.add("filterCache", filterCache); - mbeans.add("CACHE", cacheCategory); - - return mbeans; - } - - private NamedList createMockHandlerData() { - NamedList mbeans = new NamedList<>(); - NamedList queryHandlerCategory = new NamedList<>(); - NamedList selectHandler = new NamedList<>(); - NamedList selectStats = new NamedList<>(); - - selectStats.add("requests", 500L); - selectStats.add("errors", 5L); - selectStats.add("timeouts", 2L); - selectStats.add("totalTime", 10000L); - selectStats.add("avgTimePerRequest", 20.0f); - selectStats.add("avgRequestsPerSecond", 25.0f); - selectHandler.add("stats", selectStats); - queryHandlerCategory.add("/select", selectHandler); - mbeans.add("QUERYHANDLER", queryHandlerCategory); - - return mbeans; - } - - private NamedList createCompleteHandlerData() { - NamedList mbeans = new NamedList<>(); - NamedList queryHandlerCategory = new NamedList<>(); - - // Select Handler - NamedList selectHandler = new NamedList<>(); - NamedList selectStats = new NamedList<>(); - selectStats.add("requests", 500L); - selectStats.add("errors", 5L); - selectStats.add("timeouts", 2L); - selectStats.add("totalTime", 10000L); - selectStats.add("avgTimePerRequest", 20.0f); - selectStats.add("avgRequestsPerSecond", 25.0f); - selectHandler.add("stats", selectStats); - - // Update Handler - NamedList updateHandler = new NamedList<>(); - NamedList updateStats = new NamedList<>(); - updateStats.add("requests", 250L); - updateStats.add("errors", 2L); - updateStats.add("timeouts", 1L); - updateStats.add("totalTime", 5000L); - updateStats.add("avgTimePerRequest", 20.0f); - updateStats.add("avgRequestsPerSecond", 50.0f); - updateHandler.add("stats", updateStats); - - queryHandlerCategory.add("/select", selectHandler); - queryHandlerCategory.add("/update", updateHandler); - mbeans.add("QUERYHANDLER", queryHandlerCategory); - - return mbeans; + filterCache.add("lookups", 150L); + filterCache.add("hits", 120L); + filterCache.add("hitratio", 0.8f); + filterCache.add("inserts", 30L); + filterCache.add("evictions", 8L); + filterCache.add("size", 140L); + coreMetrics.add("CACHE.searcher.filterCache", filterCache); + + return coreMetrics; + } + + // Handler metrics use flat keys in the Metrics API (e.g. + // QUERY./select.requests) + private NamedList createSelectHandlerCoreMetrics() { + NamedList coreMetrics = new NamedList<>(); + coreMetrics.add("QUERY./select.requests", 500L); + coreMetrics.add("QUERY./select.errors", 5L); + coreMetrics.add("QUERY./select.timeouts", 2L); + coreMetrics.add("QUERY./select.totalTime", 10000L); + return coreMetrics; + } + + private NamedList createUpdateHandlerCoreMetrics() { + NamedList coreMetrics = new NamedList<>(); + coreMetrics.add("UPDATE./update.requests", 250L); + coreMetrics.add("UPDATE./update.errors", 2L); + coreMetrics.add("UPDATE./update.timeouts", 1L); + coreMetrics.add("UPDATE./update.totalTime", 5000L); + return coreMetrics; + } + + // Wrapped response builders (used by tests that go through + // getCacheMetrics/getHandlerMetrics) + private NamedList createMockCacheData() { + return wrapInMetricsResponse(createCacheCoreMetrics()); + } + + private NamedList createMockSelectHandlerData() { + return wrapInMetricsResponse(createSelectHandlerCoreMetrics()); + } + + private NamedList createMockSelectHandlerData(String collection) { + return wrapInMetricsResponse(createSelectHandlerCoreMetrics(), collection); + } + + private NamedList createMockUpdateHandlerData() { + return wrapInMetricsResponse(createUpdateHandlerCoreMetrics()); + } + + private NamedList wrapInMetricsResponse(NamedList coreMetrics) { + return wrapInMetricsResponse(coreMetrics, "test_collection"); + } + + private NamedList wrapInMetricsResponse(NamedList coreMetrics, String collection) { + NamedList metrics = new NamedList<>(); + metrics.add("solr.core." + collection + ".shard1.replica_n1", coreMetrics); + NamedList response = new NamedList<>(); + response.add("metrics", metrics); + return response; } // createCollection tests