refactor: Split transport implementations into modules

Split the  transport implementations into dedicated modules to improve modularity
and reduce core dependencies.

Added shared test module for common test utilities.

Key changes:

- Create mcp-transport/mcp-webflux-sse-transport module
 - Move SseClientTransport → WebFluxSseClientTransport
 - Move SseServerTransport → WebFluxSseServerTransport
 - Move related tests and utilities

- Create mcp-transport/mcp-webmvc-sse-transport module
 - Add new WebMvcSseServerTransport implementation
 - Add corresponding tests and integration tests

- Add framework-independent SSE client implementations to core module
 - Add FlowSseClient using java.net.http
 - Add HttpClientSseClientTransport using java.net.http

- Keep StdioClientTransport/StdioServerTransport in core (no external deps)

- Create mcp-test module for shared test infrastructure
 - Move abstract test base classes
 - Move shared test utilities and mocks
 - Update test dependencies

The modular approach allows users to choose between:

- Framework-specific implementations (WebFlux/WebMVC)
- Pure Java implementations (java.net.http)
- Basic implementations (STDIO)

Add breaking changes readme: 0.5.0-BREAKING-CHANGES.md

Resolves #54
This commit is contained in:
Christian Tzolov 2025-01-08 16:21:21 +01:00
commit 611f3258d6
8 changed files with 5002 additions and 1588 deletions

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.mcp.sample.client;
import org.springframework.ai.mcp.client.transport.WebFluxSseClientTransport;
import org.springframework.web.reactive.function.client.WebClient;
/**
* @author Christian Tzolov
*/
public class ClientSse {
public static void main(String[] args) {
var transport = new WebFluxSseClientTransport(WebClient.builder().baseUrl("http://localhost:8080"));
new SampleClient(transport).run();
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.mcp.sample.client;
import org.springframework.ai.mcp.client.transport.HttpClientSseClientTransport;
/**
* @author Christian Tzolov
*/
public class ClientSse2 {
public static void main(String[] args) {
var transport = new HttpClientSseClientTransport("http://localhost:8080");
new SampleClient(transport).run();
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.mcp.sample.client;
import org.springframework.ai.mcp.client.transport.ServerParameters;
import org.springframework.ai.mcp.client.transport.StdioClientTransport;
/**
* With stdio transport, the MCP server is automatically started by the client. But you
* have to build the server jar first: <pre>./mvnw clean install -DskipTests</pre>
*/
public class ClientStdio {
public static void main(String[] args) {
var stdioParams = ServerParameters.builder("java")
.args("-Dtransport.mode=stdio", "-jar",
"spring-ai-mcp-sample/target/spring-ai-mcp-sample-0.5.0-SNAPSHOT.jar")
.build();
var transport = new StdioClientTransport(stdioParams);
new SampleClient(transport).run();
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.mcp.sample.client;
import java.util.Map;
import org.springframework.ai.mcp.client.McpClient;
import org.springframework.ai.mcp.spec.ClientMcpTransport;
import org.springframework.ai.mcp.spec.McpSchema.CallToolRequest;
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
import org.springframework.ai.mcp.spec.McpSchema.GetPromptRequest;
import org.springframework.ai.mcp.spec.McpSchema.ListPromptsResult;
import org.springframework.ai.mcp.spec.McpSchema.ListToolsResult;
import org.springframework.ai.mcp.spec.McpSchema.ReadResourceRequest;
import org.springframework.ai.mcp.spec.McpTransport;
/**
* @author Christian Tzolov
*/
public class SampleClient {
private final ClientMcpTransport transport;
public SampleClient(ClientMcpTransport transport) {
this.transport = transport;
}
public void run() {
var client = McpClient.using(this.transport).sync();
client.initialize();
client.ping();
// List and demonstrate tools
ListToolsResult toolsList = client.listTools();
System.out.println("Available Tools = " + toolsList);
CallToolResult weatherResponse = client.callTool(new CallToolRequest("weather", Map.of("city", "Sofia")));
System.out.println("Weather Response = " + weatherResponse);
CallToolResult calcResponse = client
.callTool(new CallToolRequest("calculator", Map.of("operation", "multiply", "a", 2.0, "b", 3.0)));
System.out.println("Calculator Response = " + calcResponse);
CallToolResult paymentStatus = client.callTool(
new CallToolRequest("paymentTransactionStatus", Map.of("transactionId", "006", "accountName", "John")));
System.out.println("Payment Status Response = " + paymentStatus);
CallToolResult parks = client.callTool(new CallToolRequest("getBooks", Map.of("title", "Spring Framework")));
System.out.println("Books Response = " + parks);
// List and demonstrate resources
var resourcesList = client.listResources();
System.out.println("\nAvailable Resources = " + resourcesList);
// Read the system info resource
var systemInfo = client.readResource(new ReadResourceRequest("system://info"));
System.out.println("System Info = " + systemInfo);
// List and demonstrate prompts
ListPromptsResult promptsList = client.listPrompts();
System.out.println("\nAvailable Prompts = " + promptsList);
// Try the greeting prompt
var greetingResponse = client.getPrompt(new GetPromptRequest("greeting", Map.of("name", "Spring")));
System.out.println("Greeting Response = " + greetingResponse);
client.closeGracefully();
}
}

View file

@ -0,0 +1,13 @@
package org.springframework.ai.mcp.sample.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
}

View file

@ -0,0 +1,308 @@
package org.springframework.ai.mcp.sample.server;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.mcp.server.McpAsyncServer;
import org.springframework.ai.mcp.server.McpServer;
import org.springframework.ai.mcp.server.McpServer.PromptRegistration;
import org.springframework.ai.mcp.server.McpServer.ResourceRegistration;
import org.springframework.ai.mcp.server.McpServer.ToolRegistration;
import org.springframework.ai.mcp.server.transport.StdioServerTransport;
import org.springframework.ai.mcp.server.transport.WebFluxSseServerTransport;
import org.springframework.ai.mcp.spec.McpSchema;
import org.springframework.ai.mcp.spec.McpSchema.CallToolResult;
import org.springframework.ai.mcp.spec.McpSchema.GetPromptResult;
import org.springframework.ai.mcp.spec.McpSchema.LoggingMessageNotification;
import org.springframework.ai.mcp.spec.McpSchema.PromptMessage;
import org.springframework.ai.mcp.spec.McpSchema.Role;
import org.springframework.ai.mcp.spec.McpSchema.TextContent;
import org.springframework.ai.mcp.spec.ServerMcpTransport;
import org.springframework.ai.mcp.spring.ToolHelper;
import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
@Configuration
public class McpServerConfig {
private static final Logger logger = LoggerFactory.getLogger(McpServerConfig.class);
// STDIO transport
@Bean
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "stdio")
public StdioServerTransport stdioServerTransport() {
return new StdioServerTransport();
}
// SSE transport
@Bean
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
public WebFluxSseServerTransport sseServerTransport() {
return new WebFluxSseServerTransport(new ObjectMapper(), "/mcp/message");
}
// Router function for SSE transport used by Spring WebFlux to start an HTTP
// server.
@Bean
@ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse")
public RouterFunction<?> mcpRouterFunction(WebFluxSseServerTransport transport) {
return transport.getRouterFunction();
}
// SSE transport
// @Bean
// @ConditionalOnProperty(prefix = "transport", name = "mode", havingValue = "sse2")
// public HttpServletSseServerTransport sseServerTransport2() {
// var httpTransport = new HttpServletSseServerTransport(new ObjectMapper(),
// "/mcp/message");
// // Create and configure Jetty server
// Server server = new Server(8080);
// ServletContextHandler context = new
// ServletContextHandler(ServletContextHandler.SESSIONS);
// context.setContextPath("/");
// server.setHandler(context);
// // Add our SSE servlet
// context.addServlet(new ServletHolder(httpTransport), "/sse");
// // Start the server
// try {
// server.start();
// System.out.println("Server started on http://localhost:8080/sse");
// server.join();
// }
// catch (Exception e) {
// e.printStackTrace();
// try {
// server.stop();
// }
// catch (Exception e1) {
// e1.printStackTrace();
// }
// }
// return httpTransport;
// }
@Bean
public McpAsyncServer mcpServer(ServerMcpTransport transport, OpenLibrary openLibrary) { // @formatter:off
// Configure server capabilities with resource support
var capabilities = McpSchema.ServerCapabilities.builder()
.resources(false, true) // No subscribe support, but list changes notifications
.tools(true) // Tool support with list changes notifications
.prompts(true) // Prompt support with list changes notifications
.logging() // Logging support
.build();
// Create the server with both tool and resource capabilities
var server = McpServer.using(transport)
.serverInfo("MCP Demo Server", "1.0.0")
.capabilities(capabilities)
.resources(systemInfoResourceRegistration())
.prompts(greetingPromptRegistration())
.tools(calculatorToolRegistration(),
ToolHelper.toToolRegistration(
FunctionCallback.builder()
.method("paymentTransactionStatus",String.class, String.class)
.description("Get transaction payment status")
.targetClass(McpServerConfig.class)
.build()),
ToolHelper.toToolRegistration(
FunctionCallback.builder()
.function("toUpperCase", new Function<String, String>() {
@Override
public String apply(String s) {
return s.toUpperCase();
}
})
.description("To upper case")
.inputType(String.class)
.build()))
.tools(openLibraryToolRegistrations(openLibrary))
.async();
server.addTool(weatherToolRegistration(server));
return server; // @formatter:on
} // @formatter:on
public static List<ToolRegistration> openLibraryToolRegistrations(OpenLibrary openLibrary) {
var books = FunctionCallback.builder()
.method("getBooks", String.class)
.description("Get list of Books by title")
.targetObject(openLibrary)
.build();
var bookTitlesByAuthor = FunctionCallback.builder()
.method("getBookTitlesByAuthor", String.class)
.description("Get book titles by author")
.targetObject(openLibrary)
.build();
return ToolHelper.toToolRegistration(books, bookTitlesByAuthor);
}
private static ResourceRegistration systemInfoResourceRegistration() {
// Create a resource registration for system information
var systemInfoResource = new McpSchema.Resource( // @formatter:off
"system://info",
"System Information",
"Provides basic system information including Java version, OS, etc.",
"application/json", null
);
var resourceRegistration = new ResourceRegistration(systemInfoResource, (request) -> {
try {
var systemInfo = Map.of(
"javaVersion", System.getProperty("java.version"),
"osName", System.getProperty("os.name"),
"osVersion", System.getProperty("os.version"),
"osArch", System.getProperty("os.arch"),
"processors", Runtime.getRuntime().availableProcessors(),
"timestamp", System.currentTimeMillis());
String jsonContent = new ObjectMapper().writeValueAsString(systemInfo);
return new McpSchema.ReadResourceResult(
List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent)));
}
catch (Exception e) {
throw new RuntimeException("Failed to generate system info", e);
}
}); // @formatter:on
return resourceRegistration;
}
private static PromptRegistration greetingPromptRegistration() {
var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt",
List.of(new McpSchema.PromptArgument("name", "The name to greet", true)));
return new PromptRegistration(prompt, getPromptRequest -> {
String nameArgument = (String) getPromptRequest.arguments().get("name");
if (nameArgument == null) {
nameArgument = "friend";
}
var userMessage = new PromptMessage(Role.USER,
new TextContent("Hello " + nameArgument + "! How can I assist you today?"));
return new GetPromptResult("A personalized greeting message", List.of(userMessage));
});
}
private static ToolRegistration weatherToolRegistration(McpAsyncServer server) {
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {"city" : "string"}
}
""";
return new ToolRegistration(new McpSchema.Tool("weather", "Weather forecast tool by location", emptyJsonSchema),
(arguments) -> {
String city = (String) arguments.get("city");
// Create the result
var result = new CallToolResult(
List.of(new TextContent("Weather forecast for " + city + " is sunny")), false);
// Send the logging notification and ignore its completion
server
.loggingNotification(LoggingMessageNotification.builder()
.data("This is a log message from the weather tool")
.build())
.subscribe(null, error -> {
// Log any errors but don't fail the operation
logger.error("Failed to send logging notification", error);
});
return result;
});
}
private static ToolRegistration calculatorToolRegistration() {
return new ToolRegistration(new McpSchema.Tool("calculator",
"Performs basic arithmetic operations (add, subtract, multiply, divide)", """
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["add", "subtract", "multiply", "divide"],
"description": "The arithmetic operation to perform"
},
"a": {
"type": "number",
"description": "First operand"
},
"b": {
"type": "number",
"description": "Second operand"
}
},
"required": ["operation", "a", "b"]
}
"""), arguments -> {
String operation = (String) arguments.get("operation");
double a = (Double) arguments.get("a");
double b = (Double) arguments.get("b");
double result;
switch (operation) {
case "add":
result = a + b;
break;
case "subtract":
result = a - b;
break;
case "multiply":
result = a * b;
break;
case "divide":
if (b == 0) {
return new McpSchema.CallToolResult(
java.util.List.of(new McpSchema.TextContent("Division by zero")), true);
}
result = a / b;
break;
default:
return new McpSchema.CallToolResult(
java.util.List.of(new McpSchema.TextContent("Unknown operation: " + operation)),
true);
}
return new McpSchema.CallToolResult(
java.util.List.of(new McpSchema.TextContent(String.valueOf(result))), false);
});
}
public static String paymentTransactionStatus(String transactionId, String accountName) {
return "The status for " + transactionId + ", by " + accountName + " is PENDING";
}
public Function<String, String> toUpperCase() {
return String::toUpperCase;
}
@Bean
public OpenLibrary openLibrary() {
return new OpenLibrary(RestClient.builder());
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.mcp.sample.server;
import java.util.List;
import java.util.Map;
import org.springframework.web.client.RestClient;
/**
* @author Christian Tzolov
*/
public class OpenLibrary {
private RestClient restClient;
public OpenLibrary(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder.baseUrl("https://openlibrary.org").build();
}
public record Books(Integer numFound, Integer start, Boolean numFoundExact, List<Map<String, Object>> docs) {
}
public record Book(List<String> isbn, String title, List<String> authorName) {
}
public List<Book> getBooks(String title) {
Books books = restClient.get()
.uri(uriBuilder -> uriBuilder.path("/search.json").queryParam("q", title).build())
.retrieve()
.body(Books.class);
if (books == null) {
return List.of();
}
return books.docs.stream()
.map(doc -> new Book((List<String>) doc.get("isbn"), (String) doc.get("title"),
(List<String>) doc.get("author_name")))
.toList();
}
public List<String> getBookTitlesByAuthor(String authorName) {
var books = restClient.get()
.uri(uriBuilder -> uriBuilder.path("/search/authors.json").queryParam("q", authorName).build())
.retrieve()
.body(Books.class);
if (books == null) {
return List.of();
}
return books.docs.stream().map(doc -> (String) doc.get("top_work")).toList();
}
public static void main(String[] args) {
OpenLibrary openLibrary = new OpenLibrary(RestClient.builder());
List<Book> books = openLibrary.getBooks("Spring Framework");
System.out.println(books);
List<String> booksByAuthor = openLibrary.getBookTitlesByAuthor("Craig Walls");
System.out.println(booksByAuthor);
}
}

View file

@ -0,0 +1,8 @@
# spring.main.web-application-type=none
# NOTE: You must disable the banner and the console logging
# to allow the STDIO transport to work !!!
spring.main.banner-mode=off
# logging.pattern.console=
transport.mode=sse