diff --git a/mcp-inmemory-transport/README.md b/mcp-inmemory-transport/README.md new file mode 100644 index 000000000..c1d1dec47 --- /dev/null +++ b/mcp-inmemory-transport/README.md @@ -0,0 +1,221 @@ +# MCP In-Memory Transport + +In-memory transport implementation for the Model Context Protocol (MCP) Java SDK. + +## Overview + +The `mcp-inmemory-transport` module provides an in-memory transport layer for the Model Context Protocol (MCP) Java SDK. +This transport is particularly useful for testing, local development, and scenarios where you need to establish direct communication between MCP client and server components without network overhead. + +## Features + +- **In-Memory Communication**: Enables direct communication between MCP client and server without network calls +- **Reactive Streams**: Built on Project Reactor for non-blocking, reactive communication +- **Easy Integration**: Seamlessly integrates with the MCP Java SDK +- **Testing-Friendly**: Ideal for unit and integration testing of MCP implementations + +## Getting Started + +### Prerequisites + +- Java 17 or higher +- Maven or Gradle build system +- MCP Java SDK core dependency + +### Installation + +#### Maven + +Add the following dependency to your `pom.xml`: + +```xml + + io.modelcontextprotocol.sdk + mcp-inmemory-transport + 0.12.0-SNAPSHOT + +``` + +#### Gradle + +Add the following to your `build.gradle`: + +```gradle +implementation 'io.modelcontextprotocol.sdk:mcp-inmemory-transport:0.12.0-SNAPSHOT' +``` + +### Usage + +#### Basic Setup + +To use the in-memory transport, you'll need to create an `InMemoryTransport` instance and use it to create both client and server transports: + +```java +// Create the shared in-memory transport +InMemoryTransport transport = new InMemoryTransport(); + +// Create server transport provider +InMemoryServerTransportProvider serverProvider = new InMemoryServerTransportProvider(transport); + +// Create client transport +InMemoryClientTransport clientTransport = new InMemoryClientTransport(transport); +``` + +#### Creating an MCP Server + +```java +McpServer server = McpServer.sync(serverProvider) + .toolCall(McpSchema.Tool.builder() + .name("echo") + .description("Echoes the input") + .inputSchema(new McpSchema.JsonSchema( + "object", + Map.of("message", Map.of("type", "string")), + List.of("message"), + true, + null, + null)) + .build(), (exchange, request) -> { + String message = (String) request.arguments().get("message"); + return new McpSchema.CallToolResult("Echo: " + message, false); + }) + .build(); +``` + +#### Creating an MCP Client + +```java +try (McpClient client = McpClient.sync(clientTransport).build()) { + // Initialize the client + client.initialize(); + + // Call a tool + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "echo", + Map.of("message", "Hello, MCP!") + ); + + McpSchema.CallToolResult result = client.callTool(request); + System.out.println("Result: " + result.content()); +} +``` + +### Complete Example + +Here's a complete example showing how to set up and use the in-memory transport: + +```java +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.transport.inmemory.InMemoryTransport; +import io.modelcontextprotocol.transport.inmemory.InMemoryClientTransport; +import io.modelcontextprotocol.transport.inmemory.InMemoryServerTransportProvider; + +import java.util.List; +import java.util.Map; + +public class InMemoryTransportExample { + public static void main(String[] args) { + // Create the shared in-memory transport + InMemoryTransport transport = new InMemoryTransport(); + + // Create server transport provider + InMemoryServerTransportProvider serverProvider = new InMemoryServerTransportProvider(transport); + + // Create client transport + InMemoryClientTransport clientTransport = new InMemoryClientTransport(transport); + + // Set up the server + McpServer server = McpServer.sync(serverProvider) + .toolCall(McpSchema.Tool.builder() + .name("calculate") + .description("Performs a calculation") + .inputSchema(new McpSchema.JsonSchema( + "object", + Map.of( + "operation", Map.of("type", "string", "enum", List.of("add", "subtract")), + "a", Map.of("type", "number"), + "b", Map.of("type", "number") + ), + List.of("operation", "a", "b"), + true, + null, + null)) + .build(), (exchange, request) -> { + String operation = (String) request.arguments().get("operation"); + Number a = (Number) request.arguments().get("a"); + Number b = (Number) request.arguments().get("b"); + + double result; + switch (operation) { + case "add": + result = a.doubleValue() + b.doubleValue(); + break; + case "subtract": + result = a.doubleValue() - b.doubleValue(); + break; + default: + throw new IllegalArgumentException("Unknown operation: " + operation); + } + + return new McpSchema.CallToolResult(String.valueOf(result), false); + }) + .build(); + + // Use the client + try (McpClient client = McpClient.sync(clientTransport).build()) { + // Initialize the client + client.initialize(); + + // Call the calculate tool + McpSchema.CallToolRequest addRequest = new McpSchema.CallToolRequest( + "calculate", + Map.of("operation", "add", "a", 10, "b", 5) + ); + + McpSchema.CallToolResult addResult = client.callTool(addRequest); + System.out.println("10 + 5 = " + addResult.content().get(0)); + + McpSchema.CallToolRequest subtractRequest = new McpSchema.CallToolRequest( + "calculate", + Map.of("operation", "subtract", "a", 10, "b", 3) + ); + + McpSchema.CallToolResult subtractResult = client.callTool(subtractRequest); + System.out.println("10 - 3 = " + subtractResult.content().get(0)); + } + } +} +``` + +## Architecture + +The in-memory transport consists of three main components: + +1. **InMemoryTransport**: The core transport that manages the communication channels between client and server +2. **InMemoryClientTransport**: Implements the client-side transport interface +3. **InMemoryServerTransportProvider**: Provides the server-side transport implementation + +The transport uses Reactor's `Sinks.Many` to create multicast channels for message passing between client and server. + +## Testing + +The module includes comprehensive unit tests. To run them: + +```bash +mvn test +``` + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](../CONTRIBUTING.md) for details on how to contribute to this project. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Related Projects + +- [MCP Java SDK](https://github.com/modelcontextprotocol/java-sdk) +- [Model Context Protocol Specification](https://github.com/modelcontextprotocol/specification) \ No newline at end of file diff --git a/mcp-inmemory-transport/pom.xml b/mcp-inmemory-transport/pom.xml new file mode 100644 index 000000000..afbe5a2be --- /dev/null +++ b/mcp-inmemory-transport/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + + io.modelcontextprotocol.sdk + mcp-parent + 0.12.0-SNAPSHOT + ../pom.xml + + + mcp-inmemory-transport + Java SDK MCP In-Memory Transport + In-memory transport implementation for the Model Context Protocol (MCP) + + + + io.modelcontextprotocol.sdk + mcp + ${project.version} + + + org.springframework.boot + spring-boot-starter-logging + 3.4.0-SNAPSHOT + provided + + + io.projectreactor + reactor-core + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.assertj + assertj-core + ${assert4j.version} + test + + + io.projectreactor + reactor-test + test + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + diff --git a/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryClientTransport.java b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryClientTransport.java new file mode 100644 index 000000000..690d05b0f --- /dev/null +++ b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryClientTransport.java @@ -0,0 +1,61 @@ +package io.modelcontextprotocol.transport.inmemory; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.util.function.Function; + +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; + +public class InMemoryClientTransport implements McpClientTransport { + + private final InMemoryTransport transport; + + private Disposable disposable; + + public InMemoryClientTransport( InMemoryTransport transport ) { + this.transport = requireNonNull(transport, "transport cannot be null"); + } + + @Override + public Mono connect(Function, Mono> handler) { + disposable = transport.clientSink().asFlux() + .flatMap(message -> handler.apply(Mono.just(message))) + .subscribe( message -> sendMessage( message ).subscribe() ); + return Mono.empty(); + } + + @Override + public Mono sendMessage(JSONRPCMessage message) { + var result = ofNullable(transport.serverSink()) + .map( s -> s.tryEmitNext(message)) + .orElse( Sinks.EmitResult.FAIL_TERMINATED ); + return switch( result ) { + case OK -> Mono.empty(); + case FAIL_TERMINATED, + FAIL_NON_SERIALIZED, + FAIL_OVERFLOW, + FAIL_CANCELLED, + FAIL_ZERO_SUBSCRIBER -> Mono.error( () -> new Sinks.EmissionException(result) ); + }; + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return transport.objectMapper().convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + if( disposable!=null && !disposable.isDisposed() ) { + disposable.dispose(); + } + return Mono.empty(); + } + +} diff --git a/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryServerTransport.java b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryServerTransport.java new file mode 100644 index 000000000..114fc8d40 --- /dev/null +++ b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryServerTransport.java @@ -0,0 +1,49 @@ +package io.modelcontextprotocol.transport.inmemory; + +import com.fasterxml.jackson.core.type.TypeReference; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; + +public class InMemoryServerTransport implements McpServerTransport { + + private final InMemoryTransport transport; + + public InMemoryServerTransport( InMemoryTransport transport ) { + this.transport = requireNonNull(transport, "transport cannot be null"); + } + + public Sinks.Many serverSink() { + return transport.serverSink(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + var result = ofNullable( transport.clientSink()) + .map( s -> s.tryEmitNext(message) ) + .orElse( Sinks.EmitResult.FAIL_TERMINATED ); + return switch( result ) { + case OK -> Mono.empty(); + case FAIL_TERMINATED, + FAIL_NON_SERIALIZED, + FAIL_OVERFLOW, + FAIL_CANCELLED, + FAIL_ZERO_SUBSCRIBER -> Mono.error( () -> new Sinks.EmissionException(result) ); + }; + } + + @Override + public T unmarshalFrom(Object data, TypeReference typeRef) { + return transport.objectMapper().convertValue(data, typeRef); + } + +} diff --git a/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryServerTransportProvider.java b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryServerTransportProvider.java new file mode 100644 index 000000000..9bcea3cf9 --- /dev/null +++ b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryServerTransportProvider.java @@ -0,0 +1,41 @@ +package io.modelcontextprotocol.transport.inmemory; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; + +public class InMemoryServerTransportProvider implements McpServerTransportProvider { + + private final InMemoryServerTransport serverTransport; + private Disposable disposable; + + public InMemoryServerTransportProvider( InMemoryTransport transport ) { + serverTransport = new InMemoryServerTransport(transport); + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + + var session = sessionFactory.create(serverTransport); + disposable = serverTransport.serverSink().asFlux().subscribe(message -> { + session.handle(message).subscribe(); + }); + } + + @Override + public Mono closeGracefully() { + if( disposable!=null && !disposable.isDisposed() ) { + disposable.dispose(); + } + return Mono.empty(); + } + + @Override + public Mono notifyClients(String method, Object params) { + // Not implemented for in-memory transport + return Mono.empty(); + } + +} diff --git a/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryTransport.java b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryTransport.java new file mode 100644 index 000000000..d80f07cae --- /dev/null +++ b/mcp-inmemory-transport/src/main/java/io/modelcontextprotocol/transport/inmemory/InMemoryTransport.java @@ -0,0 +1,24 @@ +package io.modelcontextprotocol.transport.inmemory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Sinks; + +import static java.util.Objects.requireNonNull; + +public record InMemoryTransport( + Sinks.Many clientSink, + Sinks.Many serverSink, + ObjectMapper objectMapper +){ + public InMemoryTransport { + requireNonNull(clientSink,"clientSink cannot be null!"); + requireNonNull(serverSink,"serverSink cannot be null!"); + requireNonNull(objectMapper,"objectMapper cannot be null!"); + } + public InMemoryTransport() { + this( Sinks.many().multicast().onBackpressureBuffer(), + Sinks.many().multicast().onBackpressureBuffer(), + new ObjectMapper() ); + } +} diff --git a/mcp-inmemory-transport/src/test/java/io/modelcontextprotocol/transport/inmemory/InMemoryTransportTest.java b/mcp-inmemory-transport/src/test/java/io/modelcontextprotocol/transport/inmemory/InMemoryTransportTest.java new file mode 100644 index 000000000..cb52536e0 --- /dev/null +++ b/mcp-inmemory-transport/src/test/java/io/modelcontextprotocol/transport/inmemory/InMemoryTransportTest.java @@ -0,0 +1,80 @@ +package io.modelcontextprotocol.transport.inmemory; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class InMemoryTransportTest { + + final InMemoryTransport transport = new InMemoryTransport(); + + @BeforeEach + public void createSyncMCPServer() { + var serverProvider = new InMemoryServerTransportProvider(transport); + McpServer.sync(serverProvider) + .toolCall(McpSchema.Tool.builder() + .name("test-tool") + .description("a test tool") + .inputSchema(new McpSchema.JsonSchema("object", Map.of(), List.of(), true, null, null)) + .build(), (exchange, request) -> + new McpSchema.CallToolResult("test-result", false) + ) + .build(); + + } + @Test + void shouldSendMessageFromSyncClientToServer() { + + var clientTransport = new InMemoryClientTransport(transport); + + try(var client = McpClient.sync(clientTransport).build()) { + + client.initialize(); + var toolList = client.listTools(); + + assertFalse( toolList.tools().isEmpty() ); + assertEquals( 1, toolList.tools().size() ); + + var result = client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + + assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); + McpSchema.TextContent textContent = (McpSchema.TextContent) result.content().get(0); + assertThat(textContent.text()).isEqualTo("test-result"); + } + } + + @Test + void shouldSendMessageFromAsyncClientToServer() { + var clientTransport = new InMemoryClientTransport(transport); + + var client = McpClient.async(clientTransport).build(); + + client.initialize() + .flatMap( initResult -> client.listTools() ) + .flatMap( toolList -> { + assertFalse(toolList.tools().isEmpty()); + assertEquals(1, toolList.tools().size()); + + return client.callTool(new McpSchema.CallToolRequest("test-tool", Map.of())); + }) + .doFinally(signalType -> { + client.closeGracefully().subscribe(); + }) + .subscribe( result -> { + + assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class); + McpSchema.TextContent textContent = (McpSchema.TextContent) result.content().get(0); + assertThat(textContent.text()).isEqualTo("test-result"); + }); + } + +} diff --git a/pom.xml b/pom.xml index c0b1f7a44..3859fbf57 100644 --- a/pom.xml +++ b/pom.xml @@ -61,7 +61,7 @@ 3.26.3 5.10.2 - 5.17.0 + 5.2.0 1.20.4 1.17.5 1.21.0 @@ -103,6 +103,7 @@ mcp-bom mcp + mcp-inmemory-transport mcp-spring/mcp-spring-webflux mcp-spring/mcp-spring-webmvc mcp-test @@ -182,7 +183,7 @@ maven-surefire-plugin ${maven-surefire-plugin.version} - ${surefireArgLine} -javaagent:${org.mockito:mockito-core:jar} + ${surefireArgLine} false false