Skip to content

Commit 30a5fbd

Browse files
committed
fix: added reproduction for flacky McpCompletion test
1 parent b228ff4 commit 30a5fbd

File tree

1 file changed

+150
-0
lines changed

1 file changed

+150
-0
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright 2024-2025 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.server;
6+
7+
import java.util.List;
8+
import java.util.Map;
9+
10+
import jakarta.servlet.AsyncContext;
11+
import org.apache.catalina.LifecycleException;
12+
import org.apache.catalina.LifecycleState;
13+
import org.apache.catalina.startup.Tomcat;
14+
import org.junit.jupiter.api.AfterEach;
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.Test;
17+
18+
import io.modelcontextprotocol.client.McpClient;
19+
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
20+
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
21+
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
22+
import io.modelcontextprotocol.spec.McpError;
23+
import io.modelcontextprotocol.spec.McpSchema;
24+
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
25+
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
26+
import io.modelcontextprotocol.spec.McpSchema.ResourceReference;
27+
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
28+
import io.modelcontextprotocol.spec.McpServerSession;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
32+
/**
33+
* Reproduces the flaky McpCompletionTests.testCompletionErrorOnMissingContext failure.
34+
*
35+
* Root cause: completion handler throws McpError → server sends error via SSE → SSE write
36+
* fails (connection broken under load) → session removed → next request gets 404.
37+
*
38+
* Simulated by abruptly closing the TCP socket inside the handler before throwing.
39+
*/
40+
class McpCompletionFlakyReproductionTest {
41+
42+
private static final int PORT = TomcatTestUtil.findAvailablePort();
43+
44+
private HttpServletSseServerTransportProvider transportProvider;
45+
46+
private Tomcat tomcat;
47+
48+
@BeforeEach
49+
void setup() throws Exception {
50+
transportProvider = HttpServletSseServerTransportProvider.builder().messageEndpoint("/mcp/message").build();
51+
tomcat = TomcatTestUtil.createTomcatServer("", PORT, transportProvider);
52+
tomcat.start();
53+
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
54+
}
55+
56+
@AfterEach
57+
void teardown() {
58+
if (transportProvider != null) {
59+
transportProvider.closeGracefully().block();
60+
}
61+
if (tomcat != null) {
62+
try {
63+
tomcat.stop();
64+
tomcat.destroy();
65+
}
66+
catch (LifecycleException e) {
67+
// ignore
68+
}
69+
}
70+
}
71+
72+
@Test
73+
void reproduceRaceConditionWithArtificialDelay() throws Exception {
74+
var ref = new ResourceReference(ResourceReference.TYPE, "db://{database}/{table}");
75+
var resource = McpSchema.Resource.builder().uri("db://{database}/{table}").name("Database Table").build();
76+
77+
var server = McpServer.sync(transportProvider)
78+
.capabilities(ServerCapabilities.builder().completions().build())
79+
.resources(new McpServerFeatures.SyncResourceSpecification(resource,
80+
(exchange, req) -> new McpSchema.ReadResourceResult(List.of())))
81+
.completions(new McpServerFeatures.SyncCompletionSpecification(ref, (exchange, request) -> {
82+
if ("table".equals(request.argument().name())
83+
&& (request.context() == null || request.context().arguments() == null
84+
|| !request.context().arguments().containsKey("database"))) {
85+
try {
86+
destroySseConnection();
87+
}
88+
catch (Exception e) {
89+
throw new RuntimeException(e);
90+
}
91+
throw McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
92+
.message("Please select a database first to see available tables")
93+
.build();
94+
}
95+
return new CompleteResult(
96+
new CompleteResult.CompleteCompletion(List.of("users", "orders", "products"), 3, false));
97+
}))
98+
.build();
99+
100+
var client = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT).build()).build();
101+
client.initialize();
102+
103+
try {
104+
client.completeCompletion(new CompleteRequest(ref, new CompleteRequest.CompleteArgument("table", "")));
105+
}
106+
catch (Exception e) {
107+
// expected — SSE broken, error response may or may not arrive
108+
}
109+
110+
// Second request — session is gone → 404
111+
CompleteResult result = client
112+
.completeCompletion(new CompleteRequest(ref, new CompleteRequest.CompleteArgument("table", ""),
113+
new CompleteRequest.CompleteContext(Map.of("database", "test_db"))));
114+
assertThat(result.completion().values()).containsExactly("users", "orders", "products");
115+
116+
client.close();
117+
server.close();
118+
}
119+
120+
@SuppressWarnings("unchecked")
121+
private void destroySseConnection() throws Exception {
122+
var sessionsField = HttpServletSseServerTransportProvider.class.getDeclaredField("sessions");
123+
sessionsField.setAccessible(true);
124+
var sessions = (Map<String, McpServerSession>) sessionsField.get(transportProvider);
125+
if (sessions.isEmpty()) {
126+
return;
127+
}
128+
129+
var mcpSession = sessions.values().iterator().next();
130+
var transportField = McpServerSession.class.getDeclaredField("transport");
131+
transportField.setAccessible(true);
132+
var sessionTransport = transportField.get(mcpSession);
133+
134+
var asyncContextField = sessionTransport.getClass().getDeclaredField("asyncContext");
135+
asyncContextField.setAccessible(true);
136+
var asyncContext = (AsyncContext) asyncContextField.get(sessionTransport);
137+
138+
var servletRequest = asyncContext.getRequest();
139+
var requestField = servletRequest.getClass().getDeclaredField("request");
140+
requestField.setAccessible(true);
141+
var connectorRequest = requestField.get(servletRequest);
142+
143+
var coyoteReqField = connectorRequest.getClass().getDeclaredField("coyoteRequest");
144+
coyoteReqField.setAccessible(true);
145+
var coyoteRequest = (org.apache.coyote.Request) coyoteReqField.get(connectorRequest);
146+
147+
coyoteRequest.getResponse().action(org.apache.coyote.ActionCode.CLOSE_NOW, null);
148+
}
149+
150+
}

0 commit comments

Comments
 (0)