Client transports: make #protocolVersions() configurable (#669)
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
bc308573f3
commit
87bdf1ee49
4 changed files with 580 additions and 80 deletions
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.common;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||
import io.modelcontextprotocol.server.McpSyncServer;
|
||||
import io.modelcontextprotocol.server.McpSyncServerExchange;
|
||||
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.McpTestRequestRecordingServletFilter;
|
||||
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import org.apache.catalina.LifecycleException;
|
||||
import org.apache.catalina.LifecycleState;
|
||||
import org.apache.catalina.startup.Tomcat;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HttpClientStreamableHttpVersionNegotiationIntegrationTests {
|
||||
|
||||
private Tomcat tomcat;
|
||||
|
||||
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||
|
||||
private final McpTestRequestRecordingServletFilter requestRecordingFilter = new McpTestRequestRecordingServletFilter();
|
||||
|
||||
private final HttpServletStreamableServerTransportProvider transport = HttpServletStreamableServerTransportProvider
|
||||
.builder()
|
||||
.contextExtractor(
|
||||
req -> McpTransportContext.create(Map.of("protocol-version", req.getHeader("MCP-protocol-version"))))
|
||||
.build();
|
||||
|
||||
private final McpSchema.Tool toolSpec = McpSchema.Tool.builder()
|
||||
.name("test-tool")
|
||||
.description("return the protocol version used")
|
||||
.build();
|
||||
|
||||
private final BiFunction<McpSyncServerExchange, McpSchema.CallToolRequest, McpSchema.CallToolResult> toolHandler = (
|
||||
exchange, request) -> new McpSchema.CallToolResult(
|
||||
exchange.transportContext().get("protocol-version").toString(), null);
|
||||
|
||||
McpSyncServer mcpServer = McpServer.sync(transport)
|
||||
.capabilities(McpSchema.ServerCapabilities.builder().tools(false).build())
|
||||
.tools(new McpServerFeatures.SyncToolSpecification(toolSpec, null, toolHandler))
|
||||
.build();
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
stopTomcat();
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesLatestVersion() {
|
||||
startTomcat();
|
||||
|
||||
var client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT).build())
|
||||
.build();
|
||||
|
||||
client.initialize();
|
||||
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
|
||||
|
||||
var calls = requestRecordingFilter.getCalls();
|
||||
|
||||
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
|
||||
// GET /mcp ; POST notification/initialized ; POST tools/call
|
||||
.hasSize(3)
|
||||
.map(McpTestRequestRecordingServletFilter.Call::headers)
|
||||
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version",
|
||||
ProtocolVersions.MCP_2025_06_18));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.content()).hasSize(1)
|
||||
.first()
|
||||
.extracting(McpSchema.TextContent.class::cast)
|
||||
.extracting(McpSchema.TextContent::text)
|
||||
.isEqualTo(ProtocolVersions.MCP_2025_06_18);
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesCustomLatestVersion() {
|
||||
startTomcat();
|
||||
|
||||
var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||
.supportedProtocolVersions(List.of(ProtocolVersions.MCP_2025_06_18, "2263-03-18"))
|
||||
.build();
|
||||
var client = McpClient.sync(transport).build();
|
||||
|
||||
client.initialize();
|
||||
McpSchema.CallToolResult response = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of()));
|
||||
|
||||
var calls = requestRecordingFilter.getCalls();
|
||||
|
||||
assertThat(calls).filteredOn(c -> !c.body().contains("\"method\":\"initialize\""))
|
||||
// GET /mcp ; POST notification/initialized ; POST tools/call
|
||||
.hasSize(3)
|
||||
.map(McpTestRequestRecordingServletFilter.Call::headers)
|
||||
.allSatisfy(headers -> assertThat(headers).containsEntry("mcp-protocol-version", "2263-03-18"));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.content()).hasSize(1)
|
||||
.first()
|
||||
.extracting(McpSchema.TextContent.class::cast)
|
||||
.extracting(McpSchema.TextContent::text)
|
||||
.isEqualTo("2263-03-18");
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
private void startTomcat() {
|
||||
tomcat = TomcatTestUtil.createTomcatServer("", PORT, transport, requestRecordingFilter);
|
||||
try {
|
||||
tomcat.start();
|
||||
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start Tomcat", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void stopTomcat() {
|
||||
if (tomcat != null) {
|
||||
try {
|
||||
tomcat.stop();
|
||||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Copyright 2025 - 2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ReadListener;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
|
||||
/**
|
||||
* Simple {@link Filter} which records calls made to an MCP server.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
public class McpTestRequestRecordingServletFilter implements Filter {
|
||||
|
||||
private final List<Call> calls = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (servletRequest instanceof HttpServletRequest req) {
|
||||
var headers = Collections.list(req.getHeaderNames())
|
||||
.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(Function.identity(),
|
||||
name -> String.join(",", Collections.list(req.getHeaders(name)))));
|
||||
var request = new CachedBodyHttpServletRequest(req);
|
||||
calls.add(new Call(headers, request.getBodyAsString()));
|
||||
filterChain.doFilter(request, servletResponse);
|
||||
}
|
||||
else {
|
||||
filterChain.doFilter(servletRequest, servletResponse);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public List<Call> getCalls() {
|
||||
|
||||
return List.copyOf(calls);
|
||||
}
|
||||
|
||||
public record Call(Map<String, String> headers, String body) {
|
||||
|
||||
}
|
||||
|
||||
public static class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
|
||||
|
||||
private final byte[] cachedBody;
|
||||
|
||||
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
|
||||
super(request);
|
||||
this.cachedBody = request.getInputStream().readAllBytes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
return new CachedBodyServletInputStream(cachedBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String getBodyAsString() {
|
||||
return new String(cachedBody, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class CachedBodyServletInputStream extends ServletInputStream {
|
||||
|
||||
private InputStream cachedBodyInputStream;
|
||||
|
||||
public CachedBodyServletInputStream(byte[] cachedBody) {
|
||||
this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
try {
|
||||
return cachedBodyInputStream.available() == 0;
|
||||
}
|
||||
catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener readListener) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
return cachedBodyInputStream.read();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* Copyright 2025 - 2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
|
||||
/**
|
||||
* Simple {@link Filter} which sets a value in a thread local. Used to verify whether MCP
|
||||
* executions happen on the thread processing the request or are offloaded.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
public class McpTestServletFilter implements Filter {
|
||||
|
||||
public static final String THREAD_LOCAL_VALUE = McpTestServletFilter.class.getName();
|
||||
|
||||
private static final ThreadLocal<String> holder = new ThreadLocal<>();
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
|
||||
throws IOException, ServletException {
|
||||
holder.set(THREAD_LOCAL_VALUE);
|
||||
try {
|
||||
filterChain.doFilter(servletRequest, servletResponse);
|
||||
}
|
||||
finally {
|
||||
holder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public static String getThreadLocalValue() {
|
||||
return holder.get();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import java.io.IOException;
|
|||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.Servlet;
|
||||
import org.apache.catalina.Context;
|
||||
import org.apache.catalina.startup.Tomcat;
|
||||
|
|
@ -24,7 +25,8 @@ public class TomcatTestUtil {
|
|||
// Prevent instantiation
|
||||
}
|
||||
|
||||
public static Tomcat createTomcatServer(String contextPath, int port, Servlet servlet) {
|
||||
public static Tomcat createTomcatServer(String contextPath, int port, Servlet servlet,
|
||||
Filter... additionalFilters) {
|
||||
|
||||
var tomcat = new Tomcat();
|
||||
tomcat.setPort(port);
|
||||
|
|
@ -43,15 +45,17 @@ public class TomcatTestUtil {
|
|||
context.addChild(wrapper);
|
||||
context.addServletMappingDecoded("/*", "mcpServlet");
|
||||
|
||||
var filterDef = new FilterDef();
|
||||
filterDef.setFilterClass(McpTestServletFilter.class.getName());
|
||||
filterDef.setFilterName(McpTestServletFilter.class.getSimpleName());
|
||||
context.addFilterDef(filterDef);
|
||||
for (var filter : additionalFilters) {
|
||||
var filterDef = new FilterDef();
|
||||
filterDef.setFilter(filter);
|
||||
filterDef.setFilterName(McpTestRequestRecordingServletFilter.class.getSimpleName());
|
||||
context.addFilterDef(filterDef);
|
||||
|
||||
var filterMap = new FilterMap();
|
||||
filterMap.setFilterName(McpTestServletFilter.class.getSimpleName());
|
||||
filterMap.addURLPattern("/*");
|
||||
context.addFilterMap(filterMap);
|
||||
var filterMap = new FilterMap();
|
||||
filterMap.setFilterName(McpTestRequestRecordingServletFilter.class.getSimpleName());
|
||||
filterMap.addURLPattern("/*");
|
||||
context.addFilterMap(filterMap);
|
||||
}
|
||||
|
||||
var connector = tomcat.getConnector();
|
||||
connector.setAsyncTimeout(3000);
|
||||
|
|
|
|||
Loading…
Reference in a new issue