Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
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 io.micrometer.observation.annotation.Observed;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
Expand Down Expand Up @@ -128,6 +127,10 @@ public class CollectionService {

private static final Logger logger = LoggerFactory.getLogger(CollectionService.class);

static final String COLLECTIONS_RESOURCE_ERROR =
"Failed to list Solr collections. Retry later; if it persists,"
+ " ask the operator to check Solr availability and the server logs.";

// ========================================
// Constants for API Parameters and Paths
// ========================================
Expand Down Expand Up @@ -304,17 +307,25 @@ public SolrParams getParams() {
* available in the Solr cluster. The returned JSON contains an array of collection names.
*
* @return JSON string containing the list of collections
* @throws SolrServerException if there are errors communicating with Solr
* @throws IOException if there are I/O errors during communication
* @throws IllegalStateException with {@link #COLLECTIONS_RESOURCE_ERROR} if the collections
* cannot be listed or serialized
*/
@PreAuthorize("isAuthenticated()")
@McpResource(
uri = "solr://collections",
name = "solr-collections",
description = "List of all Solr collections available in the cluster",
mimeType = "application/json")
public String getCollectionsResource() throws SolrServerException, IOException {
return toJson(objectMapper, listCollections());
public String getCollectionsResource() {
try {
return objectMapper.writeValueAsString(listCollections());
} catch (Exception e) {
// Deliberately no cause: MCP reports the root cause's message to the client,
// and Solr and SolrJ failure text can carry internal host names, URLs and
// paths. The log is the sole record of the cause.
logger.error("Failed to list collections for the collections resource", e);
throw new IllegalStateException(COLLECTIONS_RESOURCE_ERROR);
}
}

/** Maximum number of completion suggestions returned per request. */
Expand Down
17 changes: 11 additions & 6 deletions src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*/
package org.apache.solr.mcp.server.schema;

import static org.apache.solr.mcp.server.util.JsonUtils.toJson;
import static org.apache.solr.mcp.server.util.PromptText.optionalCodeBlock;

import io.micrometer.observation.annotation.Observed;
Expand Down Expand Up @@ -119,6 +118,10 @@ public class SchemaService {

private static final Logger logger = LoggerFactory.getLogger(SchemaService.class);

static final String SCHEMA_RESOURCE_ERROR =
"Failed to retrieve the schema. Hint: call list-collections to check"
+ " the collection name; if it persists, ask the operator to check the server logs.";

/** SolrJ client for communicating with Solr server */
private final SolrClient solrClient;

Expand Down Expand Up @@ -149,6 +152,8 @@ public SchemaService(SolrClient solrClient, ObjectMapper objectMapper) {
*
* @param collection the name of the collection to retrieve schema for
* @return JSON string containing the schema representation
* @throws IllegalStateException with {@link #SCHEMA_RESOURCE_ERROR} if the schema cannot be
* retrieved or serialized
*/
@PreAuthorize("isAuthenticated()")
@McpResource(
Expand All @@ -159,13 +164,13 @@ public SchemaService(SolrClient solrClient, ObjectMapper objectMapper) {
mimeType = "application/json")
public String getSchemaResource(String collection) {
try {
return toJson(objectMapper, getSchema(collection));
return objectMapper.writeValueAsString(getSchema(collection));
} catch (Exception e) {
// Deliberately no cause: MCP reports the root cause's message to the client,
// and Solr and SolrJ failure text can carry internal host names, URLs and
// paths. The log is the sole record of the cause.
logger.error("Failed to get schema for collection: {}", collection, e);
// Serialise via Jackson rather than concatenating: an exception message
// containing a quote, backslash or newline would otherwise emit invalid
// JSON to the MCP client.
return toJson(objectMapper, Map.of("error", String.valueOf(e.getMessage())));
throw new IllegalStateException(SCHEMA_RESOURCE_ERROR);
}
}

Expand Down
59 changes: 0 additions & 59 deletions src/main/java/org/apache/solr/mcp/server/util/JsonUtils.java

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static org.junit.jupiter.api.Assertions.*;

import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
Expand All @@ -28,6 +29,7 @@
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest;
import io.modelcontextprotocol.spec.McpSchema.ResourceReference;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
Expand Down Expand Up @@ -1228,6 +1230,23 @@ void searchFailureIsAnActionableMcpToolError() {
assertFalse(extractText(result).contains("Exception"), extractText(result));
}

@Test
@Order(45)
void schemaResourceFailureIsAnMcpErrorWithoutTheSolrUrl() {
// An unknown collection makes SolrJ raise "Error from server at <solr url>:
// ...".
McpError e =
assertThrows(
McpError.class,
() ->
mcpClient.readResource(
new ReadResourceRequest("solr://no_such_collection_xyz/schema")));
String text = e.getMessage() + " " + e.getJsonRpcError();
assertTrue(text.contains("Failed to retrieve the schema"), text);
assertFalse(text.contains("Error from server"), text);
assertFalse(text.contains("http://") || text.contains("https://"), text);
}

private static String extractFirstMessageText(GetPromptResult result) {
List<PromptMessage> messages = result.messages();
assertFalse(messages.isEmpty(), "messages must not be empty");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,29 @@ void listCollections_IOException_Propagates() throws Exception {
assertThrows(IOException.class, () -> collectionService.listCollections());
}

@Test
void getCollectionsResource_Success() throws Exception {
NamedList<Object> response = new NamedList<>();
response.add("collections", Arrays.asList("collection1", "collection2"));
when(solrClient.request(any(), any())).thenReturn(response);

assertEquals("[\"collection1\",\"collection2\"]", collectionService.getCollectionsResource());
}

@Test
void getCollectionsResource_SolrFailure_DoesNotLeakCause() throws Exception {
when(solrClient.request(any(), any()))
.thenThrow(
new SolrServerException(
"Server refused connection at: http://solr-internal.corp:8983/solr"));

IllegalStateException e =
assertThrows(IllegalStateException.class, () -> collectionService.getCollectionsResource());

assertEquals(CollectionService.COLLECTIONS_RESOURCE_ERROR, e.getMessage());
assertNull(e.getCause());
}

// Helper methods — mock the Solr Metrics API response format:
// response -> "metrics" -> "solr.core.<name>" -> "CACHE.searcher.xxx" /
// "HANDLER./xxx"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,25 @@ void testGetSchema_IOException() throws Exception {
});
}

@Test
void testGetSchemaResource_SolrFailure_DoesNotLeakCause() throws Exception {
// Given
final String collectionName = "test_collection";
when(solrClient.request(any(SchemaRequest.class), eq(collectionName)))
.thenThrow(
new SolrServerException(
"Server refused connection at: http://solr-internal.corp:8983/solr"));

// When
IllegalStateException e =
assertThrows(
IllegalStateException.class, () -> schemaService.getSchemaResource(collectionName));

// Then the client gets the fixed message, never the Solr failure text
assertEquals(SchemaService.SCHEMA_RESOURCE_ERROR, e.getMessage());
assertNull(e.getCause());
}

@Test
void testGetSchema_WithEmptyCollection() {
// Given an empty collection name
Expand Down