Skip to content

Commit 15b49f2

Browse files
committed
build: add mcp unit tests
1 parent bde67d1 commit 15b49f2

9 files changed

Lines changed: 2603 additions & 3 deletions

File tree

modules/jooby-mcp/src/main/java/io/jooby/internal/mcp/transport/StreamableTransportProvider.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,11 @@ private Context handleGet(Context ctx) {
8989
ctx.setResponseType(TEXT_EVENT_STREAM);
9090
return ctx.upgrade(
9191
sse -> {
92-
sse.onClose(
93-
() -> log.debug("SSE connection closed by client for session: {}", sessionId));
9492
var sessionTransport = new StreamableMcpSessionTransport(sessionId, sse);
95-
9693
if (ctx.header(HttpHeaders.LAST_EVENT_ID).isPresent()) {
94+
sse.onClose(
95+
() -> log.debug("SSE connection closed by client for session: {}", sessionId));
96+
9797
var lastId = ctx.header(HttpHeaders.LAST_EVENT_ID).value();
9898

9999
// FIX: Replaced blocking .forEach with non-blocking .concatMap
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.mcp.transport;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
import static org.junit.jupiter.api.Assertions.assertFalse;
10+
import static org.junit.jupiter.api.Assertions.assertTrue;
11+
import static org.mockito.Mockito.mock;
12+
import static org.mockito.Mockito.never;
13+
import static org.mockito.Mockito.verify;
14+
import static org.mockito.Mockito.when;
15+
16+
import java.lang.reflect.Field;
17+
18+
import org.junit.jupiter.api.BeforeEach;
19+
import org.junit.jupiter.api.Test;
20+
import org.junit.jupiter.api.extension.ExtendWith;
21+
import org.mockito.Mock;
22+
import org.mockito.junit.jupiter.MockitoExtension;
23+
import org.slf4j.Logger;
24+
25+
import io.jooby.Context;
26+
import io.modelcontextprotocol.json.McpJsonMapper;
27+
import io.modelcontextprotocol.server.McpTransportContextExtractor;
28+
import io.modelcontextprotocol.spec.McpServerSession;
29+
import reactor.core.publisher.Mono;
30+
31+
@ExtendWith(MockitoExtension.class)
32+
class AbstractMcpTransportProviderTest {
33+
34+
@Mock McpJsonMapper jsonMapper;
35+
@Mock McpTransportContextExtractor<Context> contextExtractor;
36+
@Mock McpServerSession session1;
37+
@Mock McpServerSession session2;
38+
@Mock Logger mockLogger;
39+
40+
private AbstractMcpTransportProvider provider;
41+
42+
@BeforeEach
43+
void setup() throws Exception {
44+
// Create a concrete instance of the abstract class for testing
45+
provider =
46+
new AbstractMcpTransportProvider(jsonMapper, contextExtractor) {
47+
@Override
48+
protected String transportName() {
49+
return "test-transport";
50+
}
51+
};
52+
53+
// Inject the mock SLF4J Logger using reflection to verify logging branches
54+
Field logField = AbstractMcpTransportProvider.class.getDeclaredField("log");
55+
logField.setAccessible(true);
56+
logField.set(provider, mockLogger);
57+
}
58+
59+
@Test
60+
void testSetSessionFactory() throws Exception {
61+
McpServerSession.Factory factory = mock(McpServerSession.Factory.class);
62+
provider.setSessionFactory(factory);
63+
64+
Field factoryField = AbstractMcpTransportProvider.class.getDeclaredField("sessionFactory");
65+
factoryField.setAccessible(true);
66+
assertEquals(factory, factoryField.get(provider));
67+
}
68+
69+
// --- NOTIFY CLIENTS TESTS ---
70+
71+
@Test
72+
void testNotifyClients_EmptySessions() {
73+
provider.notifyClients("method", "params").block();
74+
75+
verify(mockLogger).debug("No active {} sessions to broadcast a message to", "test-transport");
76+
verify(mockLogger, never())
77+
.debug("Attempting to broadcast to {} active {} sessions", 0, "test-transport");
78+
}
79+
80+
@Test
81+
void testNotifyClients_Populated_DebugEnabled() {
82+
when(mockLogger.isDebugEnabled()).thenReturn(true);
83+
provider.sessions.put("sess-1", session1);
84+
when(session1.sendNotification("method", "params")).thenReturn(Mono.empty());
85+
86+
provider.notifyClients("method", "params").block();
87+
88+
// Verify debug branch was entered
89+
verify(mockLogger)
90+
.debug("Attempting to broadcast to {} active {} sessions", 1, "test-transport");
91+
verify(session1).sendNotification("method", "params");
92+
}
93+
94+
@Test
95+
void testNotifyClients_Populated_DebugDisabled() {
96+
when(mockLogger.isDebugEnabled()).thenReturn(false);
97+
provider.sessions.put("sess-1", session1);
98+
when(session1.sendNotification("method", "params")).thenReturn(Mono.empty());
99+
100+
provider.notifyClients("method", "params").block();
101+
102+
// Verify debug branch was skipped
103+
verify(mockLogger, never())
104+
.debug("Attempting to broadcast to {} active {} sessions", 1, "test-transport");
105+
verify(session1).sendNotification("method", "params");
106+
}
107+
108+
@Test
109+
void testNotifyClients_HandlesNotificationErrorGracefully() {
110+
// We don't care about debug mode for this test
111+
when(mockLogger.isDebugEnabled()).thenReturn(false);
112+
113+
provider.sessions.put("sess-1", session1);
114+
when(session1.getId()).thenReturn("sess-1-id");
115+
116+
// Simulate an error occurring during the send operation
117+
RuntimeException simulatedError = new RuntimeException("Simulated I/O failure");
118+
when(session1.sendNotification("method", "params")).thenReturn(Mono.error(simulatedError));
119+
120+
// The onErrorComplete() should swallow the error, so block() will not throw an exception
121+
provider.notifyClients("method", "params").block();
122+
123+
// Verify the doOnError block correctly logged the failure
124+
verify(mockLogger)
125+
.error(
126+
"Failed to send a message to {} session {}: {}",
127+
"test-transport",
128+
"sess-1-id",
129+
"Simulated I/O failure");
130+
}
131+
132+
// --- CLOSE GRACEFULLY TESTS ---
133+
134+
@Test
135+
void testCloseGracefully_DebugEnabled() {
136+
when(mockLogger.isDebugEnabled()).thenReturn(true);
137+
138+
provider.sessions.put("sess-1", session1);
139+
provider.sessions.put("sess-2", session2);
140+
141+
when(session1.closeGracefully()).thenReturn(Mono.empty());
142+
when(session2.closeGracefully()).thenReturn(Mono.empty());
143+
144+
assertFalse(provider.isClosing.get()); // Initially false
145+
146+
provider.closeGracefully().block();
147+
148+
// Verify doFirst actions
149+
assertTrue(provider.isClosing.get());
150+
verify(mockLogger)
151+
.debug("Initiating graceful shutdown for {} {} sessions", 2, "test-transport");
152+
153+
// Verify flatMap actions
154+
verify(session1).closeGracefully();
155+
verify(session2).closeGracefully();
156+
157+
// Verify doFinally actions
158+
assertTrue(provider.sessions.isEmpty());
159+
}
160+
161+
@Test
162+
void testCloseGracefully_DebugDisabled() {
163+
when(mockLogger.isDebugEnabled()).thenReturn(false);
164+
165+
provider.sessions.put("sess-1", session1);
166+
when(session1.closeGracefully()).thenReturn(Mono.empty());
167+
168+
provider.closeGracefully().block();
169+
170+
// Verify the debug logging branch was bypassed
171+
verify(mockLogger, never())
172+
.debug("Initiating graceful shutdown for {} {} sessions", 1, "test-transport");
173+
174+
// Verify state still updated properly
175+
assertTrue(provider.isClosing.get());
176+
assertTrue(provider.sessions.isEmpty());
177+
verify(session1).closeGracefully();
178+
}
179+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.mcp.transport;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
import static org.junit.jupiter.api.Assertions.assertNotNull;
10+
import static org.junit.jupiter.api.Assertions.assertNull;
11+
import static org.mockito.ArgumentMatchers.anyString;
12+
import static org.mockito.Mockito.verify;
13+
import static org.mockito.Mockito.when;
14+
15+
import java.util.List;
16+
17+
import org.junit.jupiter.api.BeforeEach;
18+
import org.junit.jupiter.api.Test;
19+
import org.junit.jupiter.api.extension.ExtendWith;
20+
import org.mockito.ArgumentCaptor;
21+
import org.mockito.Mock;
22+
import org.mockito.junit.jupiter.MockitoExtension;
23+
24+
import io.jooby.Context;
25+
import io.jooby.MediaType;
26+
import io.jooby.StatusCode;
27+
import io.modelcontextprotocol.spec.HttpHeaders;
28+
import io.modelcontextprotocol.spec.McpSchema;
29+
30+
@ExtendWith(MockitoExtension.class)
31+
class SendErrorTest {
32+
33+
@Mock Context ctx;
34+
35+
@BeforeEach
36+
void setup() {
37+
// By default, assume the client accepts JSON to hit the ctx.render() branch
38+
when(ctx.accept(MediaType.json)).thenReturn(true);
39+
}
40+
41+
private void assertErrorResponse(
42+
StatusCode expectedStatus, int expectedErrorCode, String expectedMessage) {
43+
verify(ctx).setResponseCode(expectedStatus);
44+
45+
ArgumentCaptor<McpSchema.JSONRPCResponse> captor =
46+
ArgumentCaptor.forClass(McpSchema.JSONRPCResponse.class);
47+
verify(ctx).render(captor.capture());
48+
49+
McpSchema.JSONRPCResponse response = captor.getValue();
50+
assertEquals(McpSchema.JSONRPC_VERSION, response.jsonrpc());
51+
assertNull(response.id());
52+
assertNull(response.result());
53+
54+
assertNotNull(response.error());
55+
assertEquals(expectedErrorCode, response.error().code());
56+
assertEquals(expectedMessage, response.error().message());
57+
assertNull(response.error().data());
58+
}
59+
60+
@Test
61+
void testServerIsShuttingDown() {
62+
SendError.serverIsShuttingDown(ctx);
63+
assertErrorResponse(
64+
StatusCode.SERVICE_UNAVAILABLE,
65+
McpSchema.ErrorCodes.INTERNAL_ERROR,
66+
"Server is shutting down");
67+
}
68+
69+
@Test
70+
void testInvalidAcceptHeader_WithJsonAccept() {
71+
List<MediaType> types = List.of(MediaType.json, TransportConstants.TEXT_EVENT_STREAM);
72+
SendError.invalidAcceptHeader(ctx, types);
73+
74+
assertErrorResponse(
75+
StatusCode.BAD_REQUEST,
76+
McpSchema.ErrorCodes.INVALID_REQUEST,
77+
"Invalid Accept header. Expected: " + types);
78+
}
79+
80+
@Test
81+
void testInvalidAcceptHeader_WithoutJsonAccept_FallsBackToStringSend() {
82+
// Force the false branch in the `send` method to ensure 100% branch coverage
83+
when(ctx.accept(MediaType.json)).thenReturn(false);
84+
85+
List<MediaType> types = List.of(TransportConstants.TEXT_EVENT_STREAM);
86+
SendError.invalidAcceptHeader(ctx, types);
87+
88+
verify(ctx).setResponseCode(StatusCode.BAD_REQUEST);
89+
// Verifies ctx.send() was used instead of ctx.render()
90+
verify(ctx).send(anyString());
91+
}
92+
93+
@Test
94+
void testMissingSessionId() {
95+
SendError.missingSessionId(ctx);
96+
assertErrorResponse(
97+
StatusCode.BAD_REQUEST,
98+
McpSchema.ErrorCodes.INVALID_REQUEST,
99+
"Session ID required in " + HttpHeaders.MCP_SESSION_ID + " header");
100+
}
101+
102+
@Test
103+
void testSessionNotFound() {
104+
SendError.sessionNotFound(ctx, "session-123");
105+
assertErrorResponse(
106+
StatusCode.NOT_FOUND,
107+
McpSchema.ErrorCodes.INVALID_REQUEST,
108+
"Session session-123 not found");
109+
}
110+
111+
@Test
112+
void testUnknownMsgType() {
113+
SendError.unknownMsgType(ctx, "session-123");
114+
assertErrorResponse(
115+
StatusCode.BAD_REQUEST,
116+
McpSchema.ErrorCodes.INVALID_REQUEST,
117+
"Unknown message type. Session ID: session-123");
118+
}
119+
120+
@Test
121+
void testMsgParseError() {
122+
SendError.msgParseError(ctx, "session-123");
123+
assertErrorResponse(
124+
StatusCode.BAD_REQUEST,
125+
McpSchema.ErrorCodes.PARSE_ERROR,
126+
"Invalid message format. Session ID: session-123");
127+
}
128+
129+
@Test
130+
void testBadRequest() {
131+
SendError.badRequest(ctx, "Custom bad request reason");
132+
assertErrorResponse(
133+
StatusCode.BAD_REQUEST, McpSchema.ErrorCodes.INVALID_REQUEST, "Custom bad request reason");
134+
}
135+
136+
@Test
137+
void testDeletionNotAllowed() {
138+
SendError.deletionNotAllowed(ctx);
139+
assertErrorResponse(
140+
StatusCode.METHOD_NOT_ALLOWED,
141+
McpSchema.ErrorCodes.INVALID_REQUEST,
142+
"Session deletion is not allowed");
143+
}
144+
145+
@Test
146+
void testInternalError_WithSessionId() {
147+
SendError.internalError(ctx, "session-123");
148+
assertErrorResponse(
149+
StatusCode.SERVER_ERROR,
150+
McpSchema.ErrorCodes.INTERNAL_ERROR,
151+
"Internal Server Error. Session ID: session-123");
152+
}
153+
154+
@Test
155+
void testInternalError_WithoutSessionId() {
156+
SendError.internalError(ctx);
157+
assertErrorResponse(
158+
StatusCode.SERVER_ERROR, McpSchema.ErrorCodes.INTERNAL_ERROR, "Internal Server Error");
159+
}
160+
161+
@Test
162+
void testCustomError() {
163+
SendError.error(ctx, StatusCode.CONFLICT, -32001, "Custom error state");
164+
assertErrorResponse(StatusCode.CONFLICT, -32001, "Custom error state");
165+
}
166+
}

0 commit comments

Comments
 (0)