Skip to content

Commit 8f03a40

Browse files
committed
feat: add WebMVC and HttpServlet stateless server transports
- Add WebMvcStatelessServerTransport for mcp-spring-webmvc module - Add HttpServletStatelessServerTransport for mcp module - Implement builder patterns for AsyncToolSpecification and SyncToolSpecification - Create AbstractStatelessIntegrationTests base class for shared test functionality - Add integration tests for both transport implementations Signed-off-by: Christian Tzolov <[email protected]>
1 parent 9d58621 commit 8f03a40

File tree

6 files changed

+1822
-0
lines changed

6 files changed

+1822
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package io.modelcontextprotocol.server.transport;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import io.modelcontextprotocol.server.McpStatelessServerHandler;
5+
import io.modelcontextprotocol.server.DefaultMcpTransportContext;
6+
import io.modelcontextprotocol.server.McpTransportContextExtractor;
7+
import io.modelcontextprotocol.spec.McpError;
8+
import io.modelcontextprotocol.spec.McpSchema;
9+
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
10+
import io.modelcontextprotocol.server.McpTransportContext;
11+
import io.modelcontextprotocol.util.Assert;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.http.MediaType;
16+
import org.springframework.web.servlet.function.RouterFunction;
17+
import org.springframework.web.servlet.function.RouterFunctions;
18+
import org.springframework.web.servlet.function.ServerRequest;
19+
import org.springframework.web.servlet.function.ServerResponse;
20+
import reactor.core.publisher.Mono;
21+
22+
import java.io.IOException;
23+
import java.util.List;
24+
25+
/**
26+
* Implementation of a WebMVC based {@link McpStatelessServerTransport}.
27+
*
28+
* <p>
29+
* This is the non-reactive version of
30+
* {@link io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport}
31+
*
32+
* @author Christian Tzolov
33+
*/
34+
public class WebMvcStatelessServerTransport implements McpStatelessServerTransport {
35+
36+
private static final Logger logger = LoggerFactory.getLogger(WebMvcStatelessServerTransport.class);
37+
38+
private final ObjectMapper objectMapper;
39+
40+
private final String mcpEndpoint;
41+
42+
private final RouterFunction<ServerResponse> routerFunction;
43+
44+
private McpStatelessServerHandler mcpHandler;
45+
46+
private McpTransportContextExtractor<ServerRequest> contextExtractor;
47+
48+
private volatile boolean isClosing = false;
49+
50+
private WebMvcStatelessServerTransport(ObjectMapper objectMapper, String mcpEndpoint,
51+
McpTransportContextExtractor<ServerRequest> contextExtractor) {
52+
Assert.notNull(objectMapper, "objectMapper must not be null");
53+
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
54+
Assert.notNull(contextExtractor, "contextExtractor must not be null");
55+
56+
this.objectMapper = objectMapper;
57+
this.mcpEndpoint = mcpEndpoint;
58+
this.contextExtractor = contextExtractor;
59+
this.routerFunction = RouterFunctions.route()
60+
.GET(this.mcpEndpoint, this::handleGet)
61+
.POST(this.mcpEndpoint, this::handlePost)
62+
.build();
63+
}
64+
65+
@Override
66+
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
67+
this.mcpHandler = mcpHandler;
68+
}
69+
70+
@Override
71+
public Mono<Void> closeGracefully() {
72+
return Mono.fromRunnable(() -> this.isClosing = true);
73+
}
74+
75+
/**
76+
* Returns the WebMVC router function that defines the transport's HTTP endpoints.
77+
* This router function should be integrated into the application's web configuration.
78+
*
79+
* <p>
80+
* The router function defines one endpoint handling two HTTP methods:
81+
* <ul>
82+
* <li>GET {messageEndpoint} - Unsupported, returns 405 METHOD NOT ALLOWED</li>
83+
* <li>POST {messageEndpoint} - For handling client requests and notifications</li>
84+
* </ul>
85+
* @return The configured {@link RouterFunction} for handling HTTP requests
86+
*/
87+
public RouterFunction<ServerResponse> getRouterFunction() {
88+
return this.routerFunction;
89+
}
90+
91+
private ServerResponse handleGet(ServerRequest request) {
92+
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
93+
}
94+
95+
private ServerResponse handlePost(ServerRequest request) {
96+
if (isClosing) {
97+
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
98+
}
99+
100+
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
101+
102+
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
103+
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
104+
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
105+
return ServerResponse.badRequest().build();
106+
}
107+
108+
try {
109+
String body = request.body(String.class);
110+
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
111+
112+
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
113+
try {
114+
McpSchema.JSONRPCResponse jsonrpcResponse = this.mcpHandler
115+
.handleRequest(transportContext, jsonrpcRequest)
116+
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
117+
.block();
118+
return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON).body(jsonrpcResponse);
119+
}
120+
catch (Exception e) {
121+
logger.error("Failed to handle request: {}", e.getMessage());
122+
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
123+
.body(new McpError("Failed to handle request: " + e.getMessage()));
124+
}
125+
}
126+
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
127+
try {
128+
this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
129+
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
130+
.block();
131+
return ServerResponse.accepted().build();
132+
}
133+
catch (Exception e) {
134+
logger.error("Failed to handle notification: {}", e.getMessage());
135+
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
136+
.body(new McpError("Failed to handle notification: " + e.getMessage()));
137+
}
138+
}
139+
else {
140+
return ServerResponse.badRequest()
141+
.body(new McpError("The server accepts either requests or notifications"));
142+
}
143+
}
144+
catch (IllegalArgumentException | IOException e) {
145+
logger.error("Failed to deserialize message: {}", e.getMessage());
146+
return ServerResponse.badRequest().body(new McpError("Invalid message format"));
147+
}
148+
catch (Exception e) {
149+
logger.error("Unexpected error handling message: {}", e.getMessage());
150+
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
151+
.body(new McpError("Unexpected error: " + e.getMessage()));
152+
}
153+
}
154+
155+
/**
156+
* Create a builder for the server.
157+
* @return a fresh {@link Builder} instance.
158+
*/
159+
public static Builder builder() {
160+
return new Builder();
161+
}
162+
163+
/**
164+
* Builder for creating instances of {@link WebMvcStatelessServerTransport}.
165+
* <p>
166+
* This builder provides a fluent API for configuring and creating instances of
167+
* WebMvcStatelessServerTransport with custom settings.
168+
*/
169+
public static class Builder {
170+
171+
private ObjectMapper objectMapper;
172+
173+
private String mcpEndpoint = "/mcp";
174+
175+
private McpTransportContextExtractor<ServerRequest> contextExtractor = (serverRequest, context) -> context;
176+
177+
private Builder() {
178+
// used by a static method
179+
}
180+
181+
/**
182+
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
183+
* messages.
184+
* @param objectMapper The ObjectMapper instance. Must not be null.
185+
* @return this builder instance
186+
* @throws IllegalArgumentException if objectMapper is null
187+
*/
188+
public Builder objectMapper(ObjectMapper objectMapper) {
189+
Assert.notNull(objectMapper, "ObjectMapper must not be null");
190+
this.objectMapper = objectMapper;
191+
return this;
192+
}
193+
194+
/**
195+
* Sets the endpoint URI where clients should send their JSON-RPC messages.
196+
* @param messageEndpoint The message endpoint URI. Must not be null.
197+
* @return this builder instance
198+
* @throws IllegalArgumentException if messageEndpoint is null
199+
*/
200+
public Builder messageEndpoint(String messageEndpoint) {
201+
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
202+
this.mcpEndpoint = messageEndpoint;
203+
return this;
204+
}
205+
206+
/**
207+
* Sets the context extractor that allows providing the MCP feature
208+
* implementations to inspect HTTP transport level metadata that was present at
209+
* HTTP request processing time. This allows to extract custom headers and other
210+
* useful data for use during execution later on in the process.
211+
* @param contextExtractor The contextExtractor to fill in a
212+
* {@link McpTransportContext}.
213+
* @return this builder instance
214+
* @throws IllegalArgumentException if contextExtractor is null
215+
*/
216+
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
217+
Assert.notNull(contextExtractor, "Context extractor must not be null");
218+
this.contextExtractor = contextExtractor;
219+
return this;
220+
}
221+
222+
/**
223+
* Builds a new instance of {@link WebMvcStatelessServerTransport} with the
224+
* configured settings.
225+
* @return A new WebMvcStatelessServerTransport instance
226+
* @throws IllegalStateException if required parameters are not set
227+
*/
228+
public WebMvcStatelessServerTransport build() {
229+
Assert.notNull(objectMapper, "ObjectMapper must be set");
230+
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
231+
232+
return new WebMvcStatelessServerTransport(objectMapper, mcpEndpoint, contextExtractor);
233+
}
234+
235+
}
236+
237+
}

0 commit comments

Comments
 (0)