Skip to content

Commit 6ccade5

Browse files
test: add MCP client integration test and reorganize test structure (#91)
* docs: update Spring AI version reference in AGENTS.md https://claude.ai/code/session_018sFVmRBfFQ3aaU8yyPDvCG * test: add MCP client integration test Add McpClientIntegrationTest that exercises the MCP server through a real MCP client over HTTP. Boots the app in HTTP mode on a random port with Solr via Testcontainers, connects an McpSyncClient, and tests the full create-collection → index → search workflow via MCP tool calls. Tests cover: ping, tool discovery, collection creation, listing, JSON/CSV indexing, search (all docs, filter query, keyword, pagination, facets), health check, collection stats, and schema introspection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: adityamparikh <aditya.m.parikh@gmail.com> * refactor(test): reorganize tests into unit and integration classes Establish consistent test naming convention: - *ServiceTest.java = unit tests (mocked dependencies, fast) - *ServiceIntegrationTest.java = integration tests (Testcontainers, real Solr) Search tests: - SearchServiceTest: unit tests with mocked SolrClient (replaces SearchServiceDirectTest) - SearchServiceIntegrationTest: real Solr via Testcontainers (new) Indexing tests: - IndexingServiceTest: unit tests with mocked SolrClient (replaces nested UnitTests + DirectTest) - IndexingServiceIntegrationTest: real Solr via Testcontainers (new) Also: - Delete redundant *DirectTest.java files - Fix TestcontainersConfiguration default Solr image to "solr:9.9-slim" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: adityamparikh <aditya.m.parikh@gmail.com> --------- Signed-off-by: adityamparikh <aditya.m.parikh@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb04f63 commit 6ccade5

9 files changed

Lines changed: 1533 additions & 1645 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Solr MCP Server is a Spring AI Model Context Protocol (MCP) server that enables
88

99
- **Status:** Apache incubating project (v0.0.2-SNAPSHOT)
1010
- **Java:** 25+ (centralized in build.gradle.kts)
11-
- **Framework:** Spring Boot 3.5.8, Spring AI 1.1.2
11+
- **Framework:** Spring Boot 3.5.13, Spring AI 1.1.4
1212
- **License:** Apache 2.0
1313

1414
## Common Commands
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.solr.mcp.server;
18+
19+
import static org.junit.jupiter.api.Assertions.*;
20+
21+
import com.fasterxml.jackson.core.type.TypeReference;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import io.modelcontextprotocol.client.McpClient;
24+
import io.modelcontextprotocol.client.McpSyncClient;
25+
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
26+
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
27+
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
28+
import io.modelcontextprotocol.spec.McpSchema.TextContent;
29+
import java.util.List;
30+
import java.util.Map;
31+
import org.junit.jupiter.api.AfterAll;
32+
import org.junit.jupiter.api.BeforeAll;
33+
import org.junit.jupiter.api.MethodOrderer;
34+
import org.junit.jupiter.api.Order;
35+
import org.junit.jupiter.api.Test;
36+
import org.junit.jupiter.api.TestInstance;
37+
import org.junit.jupiter.api.TestMethodOrder;
38+
import org.springframework.boot.test.context.SpringBootTest;
39+
import org.springframework.boot.test.web.server.LocalServerPort;
40+
import org.springframework.context.annotation.Import;
41+
import org.springframework.test.context.ActiveProfiles;
42+
import org.testcontainers.junit.jupiter.Testcontainers;
43+
44+
/**
45+
* Integration test that exercises the MCP server through a real MCP client.
46+
*
47+
* <p>
48+
* Boots the application in HTTP mode, connects an MCP client, and tests the
49+
* full create-collection → index → search workflow via MCP tool calls.
50+
*/
51+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {"http.security.enabled=false",
52+
"spring.docker.compose.enabled=false"})
53+
@ActiveProfiles("http")
54+
@Import(TestcontainersConfiguration.class)
55+
@Testcontainers(disabledWithoutDocker = true)
56+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
57+
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
58+
class McpClientIntegrationTest {
59+
60+
private static final String COLLECTION = "mcp-client-test";
61+
62+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
63+
64+
@LocalServerPort
65+
private int port;
66+
67+
private McpSyncClient mcpClient;
68+
69+
@BeforeAll
70+
void setupClient() {
71+
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build();
72+
mcpClient = McpClient.sync(transport).build();
73+
mcpClient.initialize();
74+
}
75+
76+
@AfterAll
77+
void tearDown() {
78+
if (mcpClient != null) {
79+
mcpClient.close();
80+
}
81+
}
82+
83+
@Test
84+
@Order(1)
85+
void pingServer() {
86+
assertDoesNotThrow(() -> mcpClient.ping(), "MCP ping should succeed");
87+
}
88+
89+
@Test
90+
@Order(2)
91+
void listToolsReturnsExpectedTools() {
92+
var toolsResult = mcpClient.listTools();
93+
assertNotNull(toolsResult);
94+
List<String> toolNames = toolsResult.tools().stream().map(t -> t.name()).toList();
95+
96+
assertTrue(toolNames.contains("create-collection"), "Should have create-collection tool");
97+
assertTrue(toolNames.contains("index-json-documents"), "Should have index-json-documents tool");
98+
assertTrue(toolNames.contains("search"), "Should have search tool");
99+
assertTrue(toolNames.contains("list-collections"), "Should have list-collections tool");
100+
assertTrue(toolNames.contains("check-health"), "Should have check-health tool");
101+
assertTrue(toolNames.contains("get-collection-stats"), "Should have get-collection-stats tool");
102+
assertTrue(toolNames.contains("get-schema"), "Should have get-schema tool");
103+
}
104+
105+
@Test
106+
@Order(3)
107+
void createCollection() {
108+
CallToolResult result = mcpClient
109+
.callTool(new CallToolRequest("create-collection", Map.of("name", COLLECTION)));
110+
111+
assertNotNull(result);
112+
assertNotError(result);
113+
String text = extractText(result);
114+
assertTrue(text.contains("success") || text.contains("true"), "Collection creation should succeed: " + text);
115+
}
116+
117+
@Test
118+
@Order(4)
119+
void listCollectionsContainsCreatedCollection() {
120+
CallToolResult result = mcpClient.callTool(new CallToolRequest("list-collections", Map.of()));
121+
122+
assertNotNull(result);
123+
assertNotError(result);
124+
String text = extractText(result);
125+
assertTrue(text.contains(COLLECTION), "Created collection should appear in list: " + text);
126+
}
127+
128+
@Test
129+
@Order(5)
130+
void indexJsonDocuments() {
131+
String json = """
132+
[
133+
{"id": "1", "title": "Introduction to Solr", "author": "Alice", "category": "search"},
134+
{"id": "2", "title": "MCP Protocol Guide", "author": "Bob", "category": "protocol"},
135+
{"id": "3", "title": "Spring Boot in Action", "author": "Charlie", "category": "framework"},
136+
{"id": "4", "title": "Advanced Solr Techniques", "author": "Alice", "category": "search"},
137+
{"id": "5", "title": "Building MCP Servers", "author": "Diana", "category": "protocol"}
138+
]
139+
""";
140+
141+
CallToolResult result = mcpClient
142+
.callTool(new CallToolRequest("index-json-documents", Map.of("collection", COLLECTION, "json", json)));
143+
144+
assertNotNull(result);
145+
assertNotError(result);
146+
}
147+
148+
@Test
149+
@Order(6)
150+
void checkHealthShowsIndexedDocuments() {
151+
CallToolResult result = mcpClient
152+
.callTool(new CallToolRequest("check-health", Map.of("collection", COLLECTION)));
153+
154+
assertNotNull(result);
155+
assertNotError(result);
156+
String text = extractText(result);
157+
assertTrue(text.contains("true") || text.contains("healthy"), "Collection should be healthy: " + text);
158+
assertTrue(text.contains("5"), "Should report 5 documents: " + text);
159+
}
160+
161+
@Test
162+
@Order(7)
163+
void searchAllDocuments() throws Exception {
164+
CallToolResult result = mcpClient
165+
.callTool(new CallToolRequest("search", Map.of("collection", COLLECTION, "query", "*:*", "rows", 10)));
166+
167+
assertNotNull(result);
168+
assertNotError(result);
169+
String text = extractText(result);
170+
171+
Map<String, Object> response = OBJECT_MAPPER.readValue(text, new TypeReference<>() {
172+
});
173+
assertEquals(5, getNumFound(response), "Should find all 5 documents");
174+
}
175+
176+
@Test
177+
@Order(8)
178+
void searchWithFilterQuery() throws Exception {
179+
CallToolResult result = mcpClient.callTool(new CallToolRequest("search",
180+
Map.of("collection", COLLECTION, "query", "*:*", "filterQueries", List.of("category:search"))));
181+
182+
assertNotNull(result);
183+
assertNotError(result);
184+
String text = extractText(result);
185+
186+
Map<String, Object> response = OBJECT_MAPPER.readValue(text, new TypeReference<>() {
187+
});
188+
assertEquals(2, getNumFound(response), "Should find 2 search-category documents");
189+
}
190+
191+
@Test
192+
@Order(9)
193+
void searchWithKeyword() throws Exception {
194+
CallToolResult result = mcpClient
195+
.callTool(new CallToolRequest("search", Map.of("collection", COLLECTION, "query", "title:Solr")));
196+
197+
assertNotNull(result);
198+
assertNotError(result);
199+
String text = extractText(result);
200+
201+
Map<String, Object> response = OBJECT_MAPPER.readValue(text, new TypeReference<>() {
202+
});
203+
int numFound = getNumFound(response);
204+
assertTrue(numFound >= 1, "Should find at least 1 document with 'Solr' in title: " + numFound);
205+
}
206+
207+
@Test
208+
@Order(10)
209+
void searchWithPagination() throws Exception {
210+
CallToolResult page1 = mcpClient.callTool(
211+
new CallToolRequest("search", Map.of("collection", COLLECTION, "query", "*:*", "start", 0, "rows", 2)));
212+
CallToolResult page2 = mcpClient.callTool(
213+
new CallToolRequest("search", Map.of("collection", COLLECTION, "query", "*:*", "start", 2, "rows", 2)));
214+
215+
Map<String, Object> response1 = OBJECT_MAPPER.readValue(extractText(page1), new TypeReference<>() {
216+
});
217+
Map<String, Object> response2 = OBJECT_MAPPER.readValue(extractText(page2), new TypeReference<>() {
218+
});
219+
220+
List<Map<String, Object>> docs1 = getDocuments(response1);
221+
List<Map<String, Object>> docs2 = getDocuments(response2);
222+
223+
assertEquals(2, docs1.size(), "Page 1 should have 2 documents");
224+
assertEquals(2, docs2.size(), "Page 2 should have 2 documents");
225+
assertNotEquals(docs1.get(0).get("id"), docs2.get(0).get("id"), "Pages should return different documents");
226+
}
227+
228+
@Test
229+
@Order(11)
230+
void getCollectionStats() {
231+
CallToolResult result = mcpClient
232+
.callTool(new CallToolRequest("get-collection-stats", Map.of("collection", COLLECTION)));
233+
234+
assertNotNull(result);
235+
assertNotError(result);
236+
String text = extractText(result);
237+
assertTrue(text.contains("5") || text.contains("numDocs"), "Stats should reference indexed documents: " + text);
238+
}
239+
240+
@Test
241+
@Order(12)
242+
void getSchema() {
243+
CallToolResult result = mcpClient.callTool(new CallToolRequest("get-schema", Map.of("collection", COLLECTION)));
244+
245+
assertNotNull(result);
246+
assertNotError(result);
247+
String text = extractText(result);
248+
assertFalse(text.isEmpty(), "Schema response should not be empty");
249+
}
250+
251+
@Test
252+
@Order(13)
253+
void searchWithFacets() throws Exception {
254+
CallToolResult result = mcpClient.callTool(new CallToolRequest("search",
255+
Map.of("collection", COLLECTION, "query", "*:*", "facetFields", List.of("id"), "rows", 0)));
256+
257+
assertNotNull(result);
258+
assertNotError(result);
259+
String text = extractText(result);
260+
261+
Map<String, Object> response = OBJECT_MAPPER.readValue(text, new TypeReference<>() {
262+
});
263+
@SuppressWarnings("unchecked")
264+
Map<String, Object> facets = (Map<String, Object>) response.get("facets");
265+
assertNotNull(facets, "Should have facets in response");
266+
assertTrue(facets.containsKey("id"), "Should have id facet");
267+
}
268+
269+
@Test
270+
@Order(14)
271+
void indexCsvDocuments() {
272+
String csv = """
273+
id,title,author,category
274+
6,CSV Document One,Eve,csv-test
275+
7,CSV Document Two,Frank,csv-test
276+
""";
277+
278+
CallToolResult result = mcpClient
279+
.callTool(new CallToolRequest("index-csv-documents", Map.of("collection", COLLECTION, "csv", csv)));
280+
281+
assertNotNull(result);
282+
assertNotError(result);
283+
}
284+
285+
@Test
286+
@Order(15)
287+
void searchFindsAllDocumentsAfterCsvIndexing() throws Exception {
288+
CallToolResult result = mcpClient
289+
.callTool(new CallToolRequest("search", Map.of("collection", COLLECTION, "query", "*:*", "rows", 0)));
290+
291+
Map<String, Object> response = OBJECT_MAPPER.readValue(extractText(result), new TypeReference<>() {
292+
});
293+
assertEquals(7, getNumFound(response), "Should find 7 documents (5 JSON + 2 CSV)");
294+
}
295+
296+
private static String extractText(CallToolResult result) {
297+
assertNotNull(result.content(), "Result content should not be null");
298+
assertFalse(result.content().isEmpty(), "Result content should not be empty");
299+
assertInstanceOf(TextContent.class, result.content().get(0), "Content should be TextContent");
300+
return ((TextContent) result.content().get(0)).text();
301+
}
302+
303+
private static void assertNotError(CallToolResult result) {
304+
if (Boolean.TRUE.equals(result.isError())) {
305+
String errorText = result.content().isEmpty()
306+
? "unknown error"
307+
: ((TextContent) result.content().get(0)).text();
308+
fail("MCP tool call returned error: " + errorText);
309+
}
310+
}
311+
312+
private static int getNumFound(Map<String, Object> response) {
313+
Object value = response.get("numFound");
314+
assertNotNull(value, "numFound should be present in response");
315+
return ((Number) value).intValue();
316+
}
317+
318+
@SuppressWarnings("unchecked")
319+
private static List<Map<String, Object>> getDocuments(Map<String, Object> response) {
320+
Object value = response.get("documents");
321+
assertNotNull(value, "documents should be present in response");
322+
return (List<Map<String, Object>>) value;
323+
}
324+
325+
}

src/test/java/org/apache/solr/mcp/server/TestcontainersConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public class TestcontainersConfiguration {
2929

3030
@Bean
3131
SolrContainer solr() {
32-
String solrImage = System.getProperty("solr.test.image");
32+
String solrImage = System.getProperty("solr.test.image", "solr:9.9-slim");
3333
return new SolrContainer(DockerImageName.parse(solrImage));
3434
}
3535

0 commit comments

Comments
 (0)