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..b57daf8
--- /dev/null
+++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
@@ -0,0 +1,212 @@
+/*
+ * 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;
+import org.springframework.http.MediaType;
+
+/**
+ * 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 final ObjectMapper mapper;
+
+ JsonResponseParser(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ public String getWriterType() {
+ return "json";
+ }
+
+ @Override
+ public String getContentType() {
+ return MediaType.APPLICATION_JSON_VALUE;
+ }
+
+ @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..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,7 +169,12 @@ public class SolrConfig {
* @see SolrConfigurationProperties#url()
*/
@Bean
- SolrClient solrClient(SolrConfigurationProperties properties) {
+ JsonResponseParser jsonResponseParser(ObjectMapper objectMapper) {
+ return new JsonResponseParser(objectMapper);
+ }
+
+ @Bean
+ SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser jsonResponseParser) {
String url = properties.url();
// Ensure URL is properly formatted for Solr
@@ -186,8 +192,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(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..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
@@ -22,8 +22,6 @@
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;
@@ -67,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);
- 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);
- 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);
- 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);
- 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);
- 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..bca9219
--- /dev/null
+++ b/src/test/java/org/apache/solr/mcp/server/config/SolrConfigUrlNormalizationTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.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) throws Exception {
+ SolrConfigurationProperties testProperties = new SolrConfigurationProperties(inputUrl);
+ SolrConfig solrConfig = new SolrConfig();
+
+ try (SolrClient client = solrConfig.solrClient(testProperties, new JsonResponseParser(objectMapper))) {
+ assertNotNull(client);
+
+ var httpClient = assertInstanceOf(Http2SolrClient.class, client);
+ assertEquals(expectedUrl, httpClient.getBaseURL());
+ }
+ }
+}