diff --git a/mcp/pom.xml b/mcp/pom.xml index f9df2bb..536e103 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -3,13 +3,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - - spring.ai.experiment + spring.ai.experimental mcp 0.0.1-SNAPSHOT mcp @@ -43,6 +37,25 @@ reactor-core + + org.assertj + assertj-core + 3.26.3 + test + + + org.junit.jupiter + junit-jupiter-api + 5.11.3 + test + + + org.mockito + mockito-core + 5.11.0 + test + + - spring.ai.experiment - mcp + spring.ai.experimental + mcp-spring-boot-demo 0.0.1-SNAPSHOT - mcp + mcp-spring-boot-demo Demo project for Spring Boot @@ -36,7 +36,11 @@ org.springframework.ai spring-ai-openai-spring-boot-starter - + + spring.ai.experimental + mcp + 0.0.1-SNAPSHOT + org.springframework.boot spring-boot-starter-test diff --git a/spring/src/main/java/spring/ai/mcp/McpApplication.java b/spring/src/main/java/spring/ai/mcp/McpApplication.java index a9493db..6e79325 100644 --- a/spring/src/main/java/spring/ai/mcp/McpApplication.java +++ b/spring/src/main/java/spring/ai/mcp/McpApplication.java @@ -31,6 +31,7 @@ public class McpApplication { .build(); var response = chatClient.prompt("What is the content of the test.txt file?").call().content(); + System.out.println(response); response = chatClient.prompt( @@ -56,7 +57,7 @@ public class McpApplication { var stdioParams = StdioServerParameters.builder("npx") .args("-y", "@modelcontextprotocol/server-filesystem", - "dir") + "/Users/christiantzolov/Dev/projects/demo/mcp-server/dir") .build(); McpSyncClient mcpClient = null; diff --git a/spring/src/main/java/spring/ai/mcp/McpFunctionCallback.java b/spring/src/main/java/spring/ai/mcp/McpFunctionCallback.java index 453c381..d10caa2 100644 --- a/spring/src/main/java/spring/ai/mcp/McpFunctionCallback.java +++ b/spring/src/main/java/spring/ai/mcp/McpFunctionCallback.java @@ -17,7 +17,6 @@ package spring.ai.mcp; import java.util.Map; -import spring.ai.mcp.client.McpClient; import spring.ai.mcp.client.McpSyncClient; import spring.ai.mcp.spec.McpSchema.CallToolRequest; import spring.ai.mcp.spec.McpSchema.CallToolResult; diff --git a/spring/src/main/java/spring/ai/mcp/client/McpAsyncClient.java b/spring/src/main/java/spring/ai/mcp/client/McpAsyncClient.java deleted file mode 100644 index 03c9f6d..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/McpAsyncClient.java +++ /dev/null @@ -1,185 +0,0 @@ -package spring.ai.mcp.client; - -import java.time.Duration; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import reactor.core.publisher.Mono; -import spring.ai.mcp.spec.McpAsyncTransport; -import spring.ai.mcp.spec.McpSchema; - -public class McpAsyncClient extends McpAsyncSession { - - public McpAsyncClient(McpAsyncTransport transport) { - this(transport, Duration.ofSeconds(10), new ObjectMapper()); - } - - public McpAsyncClient(McpAsyncTransport transport, Duration requestTimeout, ObjectMapper objectMapper) { - super(requestTimeout, objectMapper, transport); - } - - - /** - * The initialization phase MUST be the first interaction between client and server. - * During this phase, the client and server: - *
    - *
  • Establish protocol version compatibility
  • - *
  • Exchange and negotiate capabilities
  • - *
  • Share implementation details
  • - *
- *
- * The client MUST initiate this phase by sending an initialize request containing: - *
    - *
  • The protocol version the client supports
  • - *
  • The client's capabilities
  • - *
  • Client implementation information
  • - *
- * - * The server MUST respond with its own capabilities and information: - * {@link McpSchema.ServerCapabilities}.
- * After successful initialization, the client MUST send an initialized notification - * to indicate it is ready to begin normal operations. - * - *
- * - * Initialization - * Spec - * @return the initialize result. - */ - public Mono initialize() { - McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off - McpSchema.LATEST_PROTOCOL_VERSION, - new McpSchema.ClientCapabilities(null, new McpSchema.ClientCapabilities.RootCapabilities(true), null), - new McpSchema.Implementation("mcp-java-client", "0.0.1")); // @formatter:on - - Mono result = - this.sendRequest("initialize", initializeRequest, new TypeReference() {}); - - return result.flatMap(initializeResult -> { - if (!McpSchema.LATEST_PROTOCOL_VERSION.equals(initializeResult.protocolVersion())) { - return Mono.error( - new McpError("Unsupported protocol version from the server: " - + initializeResult.protocolVersion())); - } else { - return this.sendNotification("notifications/initialized", null) - .thenReturn(initializeResult); - } - }); - } - - /** - * Send a roots/list_changed notification. - */ - public Mono sendRootsListChanged() { - return this.sendNotification("notifications/roots/list_changed"); - } - - /** - * Send a synchronous ping request. - */ - public Mono ping() { - return this.sendRequest("ping", null, - new TypeReference() {}); - } - - // -------------------------- - // Tools - // -------------------------- - /** - * Send a tools/call request. - * @param callToolRequest the call tool request. - * @return the call tool result. - */ - public Mono callTool(McpSchema.CallToolRequest callToolRequest) { - return this.sendRequest("tools/call", callToolRequest, - new TypeReference() {}); - } - - /** - * Send a tools/list request. - * @return the list of tools result. - */ - public Mono listTools(String cursor) { - return this.sendRequest("tools/list", new McpSchema.PaginatedRequest(cursor), - new TypeReference() {}); - } - - // -------------------------- - // Resources - // -------------------------- - - /** - * Send a resources/list request. - * @param cursor the cursor - * @return the list of resources result. - */ - public Mono listResources(String cursor) { - return this.sendRequest("resources/list", new McpSchema.PaginatedRequest(cursor), - new TypeReference() {}); - } - - /** - * Send a resources/read request. - * @param resource the resource to read - * @return the resource content. - */ - public Mono readResource(McpSchema.Resource resource) { - return this.readResource(new McpSchema.ReadResourceRequest(resource.uri())); - } - - /** - * Send a resources/read request. - * @param readResourceRequest the read resource request. - * @return the resource content. - */ - public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) { - return this.sendRequest("resources/read", readResourceRequest, - new TypeReference() {}); - } - - /** - * Resource templates allow servers to expose parameterized resources using URI - * templates. Arguments may be auto-completed through the completion API. - * - * Request a list of resource templates the server has. - * @param cursor the cursor - * @return the list of resource templates result. - */ - public Mono listResourceTemplates(String cursor) { - return this.sendRequest("resources/templates/list", new McpSchema.PaginatedRequest(cursor), - new TypeReference() {}); - } - - /** - * List Changed Notification. When the list of available resources changes, servers - * that declared the listChanged capability SHOULD send a notification: - */ - public Mono sendResourcesListChanged() { - return this.sendNotification("notifications/resources/list_changed"); - } - - /** - * Subscriptions. The protocol supports optional subscriptions to resource changes. - * Clients can subscribe to specific resources and receive notifications when they - * change. - * - * Send a resources/subscribe request. - * @param subscribeRequest the subscribe request contains the uri of the resource to - * subscribe to. - */ - public Mono subscribeResource(McpSchema.SubscribeRequest subscribeRequest) { - return this.sendRequest("resources/subscribe", subscribeRequest, - new TypeReference() {}); - } - - /** - * Send a resources/unsubscribe request. - * @param unsubscribeRequest the unsubscribe request contains the uri of the resource - * to unsubscribe from. - */ - public Mono unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) { - return this.sendRequest("resources/unsubscribe", unsubscribeRequest, - new TypeReference() {}); - } -} diff --git a/spring/src/main/java/spring/ai/mcp/client/McpAsyncSession.java b/spring/src/main/java/spring/ai/mcp/client/McpAsyncSession.java deleted file mode 100644 index 7adb4f1..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/McpAsyncSession.java +++ /dev/null @@ -1,131 +0,0 @@ -package spring.ai.mcp.client; - -import java.time.Duration; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import reactor.core.publisher.Mono; -import reactor.core.publisher.MonoSink; -import spring.ai.mcp.spec.McpAsyncTransport; -import spring.ai.mcp.spec.McpTransport; - -import org.springframework.util.Assert; - -import spring.ai.mcp.spec.McpSchema; - -public class McpAsyncSession { - - private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); - - private final Duration requestTimeout; - - private final ObjectMapper objectMapper; - - private final McpAsyncTransport transport; - - public McpAsyncSession(Duration requestTimeout, ObjectMapper objectMapper, McpAsyncTransport transport) { - - Assert.notNull(requestTimeout, "Request timeout must not be null"); - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - Assert.notNull(transport, "Transport must not be null"); - - this.requestTimeout = requestTimeout; - this.objectMapper = objectMapper; - this.transport = transport; - - this.transport.setMessageHandler(message -> { - switch (message) { - case McpSchema.JSONRPCResponse response -> { - var sink = pendingResponses.remove(response.id()); - if (sink == null) { - System.out.println("Unexpected response for unkown id " + response.id()); - } - else { - sink.success(response); - } - } - case McpSchema.JSONRPCRequest request -> { - System.out.println("Client does not yet support server requests"); - } - case McpSchema.JSONRPCNotification notification -> { - System.out.println("Notifications not yet supported"); - } - } - }); - - this.transport.setErrorHandler(error -> System.out.println("Error received " + error)); - - this.transport.start(); - } - - public Mono sendRequest(String method, Object requestParams, TypeReference typeRef) { - String requestId = UUID.randomUUID().toString(); - - return Mono.create(sink -> { - this.pendingResponses.put(requestId, sink); - McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, - requestId, requestParams); - try { - // TODO: This is non-blocking, but it's actually a synchronous call, - // perhaps there's no need to make it return Mono? - this.transport.sendMessage(jsonrpcRequest) - // TODO: It's most efficient to create a dedicated - // Subscriber here - .subscribe(v -> { - }, e -> { - this.pendingResponses.remove(requestId); - sink.error(e); - }); - } - catch (Exception e) { - sink.error(e); - } - }).timeout(this.requestTimeout).handle((jsonRpcResponse, s) -> { - if (jsonRpcResponse.error() != null) { - s.error(new McpError(jsonRpcResponse.error())); - } - else { - if (typeRef.getType().getTypeName().equals("java.lang.Void")) { - s.complete(); - } - else { - s.next(this.objectMapper.convertValue(jsonRpcResponse.result(), typeRef)); - } - } - }); - } - - public static class McpError extends RuntimeException { - - public McpError(Object error) { - super(error.toString()); - } - - } - - public Mono sendNotification(String method) { - return sendNotification(method, null); - } - - public Mono sendNotification(String method, Map params) { - McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, - method, params); - try { - // TODO: make it non-blocking - this.transport.sendMessage(jsonrpcNotification); - } - catch (Exception e) { - return Mono.error(new McpError(e)); - } - return Mono.empty(); - } - - public Mono closeGracefully(Duration timeout) { - // TODO handle the timeout in transport - return Mono.fromRunnable(this.transport::close); - } - -} diff --git a/spring/src/main/java/spring/ai/mcp/client/McpClient.java b/spring/src/main/java/spring/ai/mcp/client/McpClient.java deleted file mode 100644 index d85125a..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/McpClient.java +++ /dev/null @@ -1,83 +0,0 @@ -/* -* 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 spring.ai.mcp.client; - -import java.time.Duration; -import java.util.concurrent.CompletableFuture; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import spring.ai.mcp.spec.McpAsyncTransport; -import spring.ai.mcp.spec.McpTransport; -import spring.ai.mcp.spec.McpSchema; -import spring.ai.mcp.spec.McpSession; -import spring.ai.mcp.spec.McpTransport; -import spring.ai.mcp.spec.McpSchema.CallToolRequest; -import spring.ai.mcp.spec.McpSchema.CallToolResult; -import spring.ai.mcp.spec.McpSchema.ClientCapabilities; -import spring.ai.mcp.spec.McpSchema.Implementation; -import spring.ai.mcp.spec.McpSchema.InitializeRequest; -import spring.ai.mcp.spec.McpSchema.InitializeResult; -import spring.ai.mcp.spec.McpSchema.ListResourceTemplatesResult; -import spring.ai.mcp.spec.McpSchema.ListResourcesResult; -import spring.ai.mcp.spec.McpSchema.ListToolsResult; -import spring.ai.mcp.spec.McpSchema.PaginatedRequest; -import spring.ai.mcp.spec.McpSchema.ReadResourceRequest; -import spring.ai.mcp.spec.McpSchema.Resource; -import spring.ai.mcp.spec.McpSchema.SubscribeRequest; -import spring.ai.mcp.spec.McpSchema.UnsubscribeRequest; -import spring.ai.mcp.spec.McpSchema.ClientCapabilities.RootCapabilities; - -/** - * The MCP client is the main entry point for interacting with the Model Context Protocol - * (MCP) server. - * - * - * @author Christian Tzolov - * @since 1.0.0 - */ -public class McpClient { - - private McpClient() { - } - - // TODO: introduce a builder like: - // McpClient.using(transport) - // .withRequestTimeout(Duration.ofSeconds(5)) - // .withObjectMapper(objectMapper); <- or even a more sophisticated - // JSONtoPOJOCodec type - // .sync(); - - public static McpAsyncClient async(McpAsyncTransport transport) { - return new McpAsyncClient(transport); - } - - public static McpAsyncClient async(McpAsyncTransport transport, Duration requestTimeout, - ObjectMapper objectMapper) { - return new McpAsyncClient(transport, requestTimeout, objectMapper); - } - - public static McpSyncClient sync(McpAsyncTransport transport) { - return new McpSyncClient(async(transport)); - } - - public static McpSyncClient sync(McpAsyncTransport transport, Duration requestTimeout, - ObjectMapper objectMapper) { - return new McpSyncClient(async(transport, requestTimeout, objectMapper)); - } - - -} diff --git a/spring/src/main/java/spring/ai/mcp/client/McpSyncClient.java b/spring/src/main/java/spring/ai/mcp/client/McpSyncClient.java deleted file mode 100644 index 370ac9f..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/McpSyncClient.java +++ /dev/null @@ -1,180 +0,0 @@ -package spring.ai.mcp.client; - -import java.time.Duration; - -import spring.ai.mcp.spec.McpSchema; - -import org.springframework.util.Assert; - -public class McpSyncClient implements AutoCloseable { - - // TODO: Consider providing a client config to set this properly - // this is currently a concern only because AutoCloseable is used - perhaps it is - // not a requirement? - private static final long DEFAULT_CLOSE_TIMEOUT_MS = 10_000L; - - McpAsyncClient delegate; - - public McpSyncClient(McpAsyncClient delegate) { - Assert.notNull(delegate, "McpAsyncClient must not be null"); - this.delegate = delegate; - } - - @Override - public void close() { - delegate.closeGracefully(Duration.ofMillis(DEFAULT_CLOSE_TIMEOUT_MS)).block(); - } - - /** - * The initialization phase MUST be the first interaction between client and server. - * During this phase, the client and server: - *
    - *
  • Establish protocol version compatibility
  • - *
  • Exchange and negotiate capabilities
  • - *
  • Share implementation details
  • - *
- *
- * The client MUST initiate this phase by sending an initialize request containing: - *
    - *
  • The protocol version the client supports
  • - *
  • The client's capabilities
  • - *
  • Client implementation information
  • - *
- * - * The server MUST respond with its own capabilities and information: - * {@link McpSchema.ServerCapabilities}.
- * After successful initialization, the client MUST send an initialized notification - * to indicate it is ready to begin normal operations. - * - *
- * - * Initialization - * Spec - * @return the initialize result. - */ - public McpSchema.InitializeResult initialize() { - // TODO: block takes no argument here as we assume the async client is - // configured with a requestTimeout at all times - return delegate.initialize() - .block(); - } - - /** - * Send a roots/list_changed notification. - */ - public void sendRootsListChanged() { - delegate.sendRootsListChanged() - .block(); - } - - /** - * Send a synchronous ping request. - */ - public void ping() { - delegate.ping() - .block(); - } - - // -------------------------- - // Tools - // -------------------------- - /** - * Send a tools/call request. - * @param callToolRequest the call tool request. - * @return the call tool result. - */ - public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolRequest) { - return delegate.callTool(callToolRequest) - .block(); - } - - /** - * Send a tools/list request. - * @return the list of tools result. - */ - public McpSchema.ListToolsResult listTools(String cursor) { - return delegate.listTools(cursor) - .block(); - } - - // -------------------------- - // Resources - // -------------------------- - - /** - * Send a resources/list request. - * @param cursor the cursor - * @return the list of resources result. - */ - public McpSchema.ListResourcesResult listResources(String cursor) { - return delegate.listResources(cursor) - .block(); - } - - /** - * Send a resources/read request. - * @param resource the resource to read - * @return the resource content. - */ - public McpSchema.ReadResourceResult readResource(McpSchema.Resource resource) { - return delegate.readResource(resource) - .block(); - } - - /** - * Send a resources/read request. - * @param readResourceRequest the read resource request. - * @return the resource content. - */ - public McpSchema.ReadResourceResult readResource(McpSchema.ReadResourceRequest readResourceRequest) { - return delegate.readResource(readResourceRequest) - .block(); - } - - /** - * Resource templates allow servers to expose parameterized resources using URI - * templates. Arguments may be auto-completed through the completion API. - * - * Request a list of resource templates the server has. - * @param cursor the cursor - * @return the list of resource templates result. - */ - public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor) { - return delegate.listResourceTemplates(cursor) - .block(); - } - - /** - * List Changed Notification. When the list of available resources changes, servers - * that declared the listChanged capability SHOULD send a notification: - */ - public void sendResourcesListChanged() { - delegate.sendResourcesListChanged() - .block(); - } - - /** - * Subscriptions. The protocol supports optional subscriptions to resource changes. - * Clients can subscribe to specific resources and receive notifications when they - * change. - * - * Send a resources/subscribe request. - * @param subscribeRequest the subscribe request contains the uri of the resource to - * subscribe to. - */ - public void subscribeResource(McpSchema.SubscribeRequest subscribeRequest) { - delegate.subscribeResource(subscribeRequest) - .block(); - } - - /** - * Send a resources/unsubscribe request. - * @param unsubscribeRequest the unsubscribe request contains the uri of the resource - * to unsubscribe from. - */ - public void unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) { - delegate.unsubscribeResource(unsubscribeRequest) - .block(); - } -} diff --git a/spring/src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java b/spring/src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java deleted file mode 100644 index d9617f2..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java +++ /dev/null @@ -1,130 +0,0 @@ -package spring.ai.mcp.client.stdio; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; - -/** - * Server parameters for stdio client. - */ -public class StdioServerParameters { - - // Environment variables to inherit by default - private static final List DEFAULT_INHERITED_ENV_VARS = System.getProperty("os.name") - .toLowerCase() - .contains("win") - ? Arrays.asList("APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", - "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE") - : Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"); - - @JsonProperty("command") - private String command; - - @JsonProperty("args") - private List args = new ArrayList<>(); - - @JsonProperty("env") - private Map env; - - private StdioServerParameters(String command, List args, Map env) { - Assert.hasText(command, "Command must not be empty"); - Assert.notNull(args, "Arguments must not be null"); - - this.command = command; - this.args = args; - this.env = new HashMap<>(getDefaultEnvironment()); - if (!CollectionUtils.isEmpty(env)) { - this.env.putAll(env); - } - } - - public String getCommand() { - return this.command; - } - - public List getArgs() { - return this.args; - } - - public Map getEnv() { - return this.env; - } - - public static Builder builder(String command) { - return new Builder(command); - } - - public static class Builder { - - private String command; - - private List args = new ArrayList<>(); - - private Map env = new HashMap<>(); - - public Builder(String command) { - Assert.hasText(command, "Command must not be empty"); - this.command = command; - } - - public Builder args(String... args) { - Assert.notNull(args, "Arguments must not be null"); - this.args = Arrays.asList(args); - return this; - } - - public Builder args(List args) { - Assert.notNull(args, "Arguments must not be null"); - this.args = new ArrayList<>(args); - return this; - } - - public Builder arg(String arg) { - Assert.hasText(arg, "Argument must not be empty"); - this.args.add(arg); - return this; - } - - public Builder env(Map env) { - if (!CollectionUtils.isEmpty(env)) { - this.env.putAll(env); - } - return this; - } - - public Builder addEnvVar(String key, String value) { - Assert.hasText(key, "Environment variable key must not be empty"); - Assert.notNull(value, "Environment variable value must not be null"); - this.env.put(key, value); - return this; - } - - public StdioServerParameters build() { - return new StdioServerParameters(command, args, env); - } - - } - - /** - * Returns a default environment object including only environment variables - * deemed safe to inherit. - */ - private static Map getDefaultEnvironment() { - return System.getenv() - .entrySet() - .stream() - .filter(entry -> DEFAULT_INHERITED_ENV_VARS.contains(entry.getKey())) - .filter(entry -> entry.getValue() != null) - .filter(entry -> !entry.getValue().startsWith("()")) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - -} \ No newline at end of file diff --git a/spring/src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java b/spring/src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java deleted file mode 100644 index b4c275d..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java +++ /dev/null @@ -1,243 +0,0 @@ -package spring.ai.mcp.client.stdio; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.IOException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Executors; - -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; -import spring.ai.mcp.spec.DefaultMcpTransport; -import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; -import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; -import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; -import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; - -import org.springframework.ai.model.ModelOptionsUtils; -import org.springframework.util.Assert; - -/** - * Stdio client for communicating with a server process. - */ -public class StdioServerTransport extends DefaultMcpTransport { - - private Process process; - - private BufferedReader processErrorReader; - - private BufferedReader processReader; - - private BufferedWriter processWriter; - - private Scheduler inboundScheduler; - private Scheduler outboundScheduler; - private Scheduler errorScheduler; - - private volatile boolean isRunning; - - private final StdioServerParameters params; - - public StdioServerTransport(StdioServerParameters params) { - this(params, Duration.ofMillis(100)); - } - - public StdioServerTransport(StdioServerParameters params, Duration writeTimeout) { - super(writeTimeout); - - Assert.notNull(params, "Server parameters must not be null"); - - this.params = params; - - // Start threads - this.inboundScheduler = - Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), - "inbound"); - this.outboundScheduler = - Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), - "outbound"); - this.errorScheduler = - Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), - "error"); - } - - @Override - public void start() { - // Prepare command and environment - List fullCommand = new ArrayList<>(); - fullCommand.add(params.getCommand()); - fullCommand.addAll(params.getArgs()); - - ProcessBuilder processBuilder = new ProcessBuilder(fullCommand); - processBuilder.environment().putAll(params.getEnv()); - - // Start the process - try { - this.process = processBuilder.start(); - } - catch (IOException e) { - throw new RuntimeException("Failed to start process with command: " + fullCommand, e); - } - - // Validate process streams - if (this.process.getInputStream() == null || process.getOutputStream() == null) { - this.process.destroy(); - throw new RuntimeException("Process input or output stream is null"); - } - - // Initialize readers and writers - this.processErrorReader = this.process.errorReader(); - this.processReader = this.process.inputReader(); - this.processWriter = this.process.outputWriter(); - - // Start threads - this.isRunning = true; - startInboundProcessing(); - startOutboundProcessing(); - startErrorProcessing(); - - } - - public void awaitForExit() { - try { - this.process.waitFor(); - } - catch (InterruptedException e) { - throw new RuntimeException("Process interrupted", e); - } - } - - private void startErrorProcessing() { - this.errorScheduler.schedule(() -> { - try { - String line; - while (isRunning && processErrorReader != null && (line = processErrorReader.readLine()) != null) { - try { - System.out.println("Received error line: " + line); - // TODO: handle errors, etc. - this.getErrorSink().tryEmitNext(line); - } - catch (Exception e) { - throw new RuntimeException(e); - } - } - } - catch (IOException e) { - if (this.isRunning) { - throw new RuntimeException(e); - } - } - finally { - this.isRunning = false; - } - }); - } - - private JSONRPCMessage deserializeJsonRpcMessage(String jsonText) throws IOException { - - var map = ModelOptionsUtils.jsonToMap(jsonText); - - // Determine message type based on specific JSON structure - if (map.containsKey("method") && map.containsKey("id")) { - return ModelOptionsUtils.OBJECT_MAPPER.convertValue(map, JSONRPCRequest.class); - } - else if (map.containsKey("method") && !map.containsKey("id")) { - return ModelOptionsUtils.OBJECT_MAPPER.convertValue(map, JSONRPCNotification.class); - } - else if (map.containsKey("result") || map.containsKey("error")) { - return ModelOptionsUtils.OBJECT_MAPPER.convertValue(map, JSONRPCResponse.class); - } - - throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText); - } - - private void startInboundProcessing() { - this.inboundScheduler.schedule(() -> { - try { - String line; - while (this.isRunning && this.processReader != null && (line = this.processReader.readLine()) != null) { - try { - JSONRPCMessage message = deserializeJsonRpcMessage(line); - if (!this.getInboundSink().tryEmitNext(message).isSuccess()) { - // TODO: Back off, reschedule, give up? - throw new RuntimeException("Failed to enqueue message"); - } - } - catch (Exception e) { - throw new RuntimeException(e); - } - } - } - catch (IOException e) { - if (isRunning) { - throw new RuntimeException(e); - } - } - finally { - isRunning = false; - } - }); - } - - private void startOutboundProcessing() { - this.getOutboundSink() - .asFlux() - // this bit is important since writes come from user threads and we - // want to ensure that the actual writing happens on a dedicated thread - .publishOn(outboundScheduler) - .handle((message, s) -> { - if (message != null) { - try { - this.processWriter.write(ModelOptionsUtils.toJsonString(message)); - this.processWriter.newLine(); - this.processWriter.flush(); - s.next(message); - } - catch (IOException e) { - s.error(new RuntimeException(e)); - } - } - }) - .subscribe(); - } - - // TODO: provide a non-blocking variant with graceful option - public void stop() { - this.outboundScheduler.dispose(); - this.inboundScheduler.dispose(); - this.errorScheduler.dispose(); - - // Destroy process - if (this.process != null) { - this.process.destroyForcibly(); - } - } - - @Override - public void close() { - stop(); - - super.close(); // Do we need this? - - // Close resources - if (this.processReader != null) { - try { - this.processReader.close(); - } - catch (IOException e) { - e.printStackTrace(); - } - } - if (this.processWriter != null) { - try { - this.processWriter.close(); - } - catch (IOException e) { - e.printStackTrace(); - } - } - } - -} \ No newline at end of file diff --git a/spring/src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java b/spring/src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java deleted file mode 100644 index 0dc61b5..0000000 --- a/spring/src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java +++ /dev/null @@ -1,87 +0,0 @@ -/* -* 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 spring.ai.mcp.client.util; - -import java.util.Map; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.ObjectMapper; -import spring.ai.mcp.client.util.McpServerParameterParser.McpServers.McpServer; - -/** - * @author Christian Tzolov - * @since 1.0.0 - */ - -public class McpServerParameterParser { - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record McpServers(// @formatter:off - @JsonProperty("mcpServers") Map mcpServers) { - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record McpServer( - @JsonProperty("command") String command, - @JsonProperty("args") String[] args, - @JsonProperty("env") Map env) { - } - } // @formatter:on - - public static void main(String[] args) throws Exception { - ObjectMapper objectMapper = new ObjectMapper(); - - // Assuming the JSON is in a file named "servers.json" - String jsonInput = """ - { - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"] - }, - "git": { - "command": "uvx", - "args": ["mcp-server-git", "--repository", "path/to/git/repo"] - }, - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "" - } - }, - "postgres": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"] - } - } - } - """; - - // Deserialize JSON into McpServers - McpServers servers = objectMapper.readValue(jsonInput, McpServers.class); - - // Access individual servers - for (Map.Entry entry : servers.mcpServers().entrySet()) { - System.out.println("Key: " + entry.getKey()); - System.out.println("Command: " + entry.getValue().command()); - System.out.println("Arguments: " + String.join(", ", entry.getValue().args())); - System.out.println("Environment Variables: " + entry.getValue().env()); - System.out.println("-----------"); - } - } - -} diff --git a/spring/src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java b/spring/src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java deleted file mode 100644 index 62e476b..0000000 --- a/spring/src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java +++ /dev/null @@ -1,116 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import java.time.Duration; -import java.util.function.Consumer; - -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; -import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; - -/** - * @author Christian Tzolov - * @since 1.0.0 - */ -public class DefaultMcpTransport implements McpAsyncTransport { - - private final Sinks.Many errorSink; - - private final Sinks.Many inboundSink; - - private final Sinks.Many outboundSink; - - private final Duration writeTimeout; - - private Consumer messageReader = message -> System.out.println("Received message: " + message); - - private Consumer errorReader = error -> System.err.println("Received error: " + error); - - public DefaultMcpTransport(Duration readTimeout) { - - // TODO: consider the effects of buffering here -> the inter-process pipes are - // independent and the notifications can flood the client/server. - // Potentially, the interest in reading could be communicated from one party - // to the other so the Blocking IO Threads can pause consuming the stream - // buffers when there is no expectation for reading. - - this.errorSink = Sinks.many().unicast().onBackpressureBuffer(); - this.inboundSink = Sinks.many().unicast().onBackpressureBuffer(); - this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(); - this.writeTimeout = readTimeout; - - this.handleIncomingMessages(); - } - - private void handleIncomingMessages() { - this.inboundSink.asFlux() - .subscribe(message -> this.messageReader.accept(message)); - } - - private void handleIncomingErrors() { - this.errorSink.asFlux().subscribe(e -> { - this.errorReader.accept(e); - System.err.println(e); - }); - } - - protected Sinks.Many getInboundSink() { - return inboundSink; - } - - protected Sinks.Many getOutboundSink() { - return outboundSink; - } - - protected Sinks.Many getErrorSink() { - return errorSink; - } - - public void setMessageHandler(Consumer messageReader) { - this.messageReader = messageReader; - } - - public void setErrorHandler(Consumer errorReader) { - this.errorReader = errorReader; - } - - // Start processing incoming messages - public void start() { - this.handleIncomingErrors(); - this.handleIncomingMessages(); - } - - // Close the transport - @Override - public void close() { - // this.onClose(); - } - - @Override - public Mono sendMessage(JSONRPCMessage message) { - if (this.outboundSink.tryEmitNext(message).isSuccess()) { - // TODO: essentially we could reschedule ourselves in some time and make - // another attempt with the already read data but pause reading until - // success - // In this approach we delegate the retry and the backpressure onto the - // caller. This might be enough for most cases. - return Mono.empty(); - } else { - return Mono.error(new RuntimeException("Failed to enqueue message")); - } - } -} diff --git a/spring/src/main/java/spring/ai/mcp/spec/McpAsyncTransport.java b/spring/src/main/java/spring/ai/mcp/spec/McpAsyncTransport.java deleted file mode 100644 index b8109c7..0000000 --- a/spring/src/main/java/spring/ai/mcp/spec/McpAsyncTransport.java +++ /dev/null @@ -1,39 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import java.util.function.Consumer; - -import reactor.core.publisher.Mono; -import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; - -/** - * @author Christian Tzolov - * @since 1.0.0 - */ - -public interface McpAsyncTransport extends AutoCloseable{ - - void start(); - - void close(); - - void setMessageHandler(Consumer messageReader); - - void setErrorHandler(Consumer errorReader); - - Mono sendMessage(JSONRPCMessage message); -} diff --git a/spring/src/main/java/spring/ai/mcp/spec/McpSchema.java b/spring/src/main/java/spring/ai/mcp/spec/McpSchema.java deleted file mode 100644 index 2b0ee17..0000000 --- a/spring/src/main/java/spring/ai/mcp/spec/McpSchema.java +++ /dev/null @@ -1,524 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import com.fasterxml.jackson.annotation.JsonTypeInfo.As; - -/** - * Based on the JSON-RPC 2.0 - * specification and the Model - * Context Protocol Schema. - * - * @author Christian Tzolov - * @since 1.0.0 - */ -public class McpSchema { - - public static final String LATEST_PROTOCOL_VERSION = "2024-11-05"; - - public static final String JSONRPC_VERSION = "2.0"; - - // --------------------------- - // JSON-RPC Error Codes - // --------------------------- - public final class ErrorCodes { - - public static final int PARSE_ERROR = -32700; - - public static final int INVALID_REQUEST = -32600; - - public static final int METHOD_NOT_FOUND = -32601; - - public static final int INVALID_PARAMS = -32602; - - public static final int INTERNAL_ERROR = -32603; - - } - - public sealed interface Request permits InitializeRequest, CallToolRequest, CreateMessageRequest, CompleteRequest { - - } - - // --------------------------- - // JSON-RPC Message Types - // --------------------------- - public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotification, JSONRPCResponse { - - String jsonrpc(); - - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record JSONRPCRequest( // @formatter:off - @JsonProperty("jsonrpc") String jsonrpc, - @JsonProperty("method") String method, - @JsonProperty("id") Object id, - @JsonProperty("params") Object params) implements JSONRPCMessage { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record JSONRPCNotification( // @formatter:off - @JsonProperty("jsonrpc") String jsonrpc, - @JsonProperty("method") String method, - @JsonProperty("params") Map params) implements JSONRPCMessage { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record JSONRPCResponse( // @formatter:off - @JsonProperty("jsonrpc") String jsonrpc, - @JsonProperty("id") Object id, - @JsonProperty("result") Object result, - @JsonProperty("error") JSONRPCError error) implements JSONRPCMessage { - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record JSONRPCError( - @JsonProperty("code") int code, - @JsonProperty("message") String message, - @JsonProperty("data") Object data) { - } - }// @formatter:on - - // --------------------------- - // Initialization - // --------------------------- - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record InitializeRequest( // @formatter:off - @JsonProperty("protocolVersion") String protocolVersion, - @JsonProperty("capabilities") ClientCapabilities capabilities, - @JsonProperty("clientInfo") Implementation clientInfo) implements Request { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record InitializeResult( // @formatter:off - @JsonProperty("protocolVersion") String protocolVersion, - @JsonProperty("capabilities") ServerCapabilities capabilities, - @JsonProperty("serverInfo") Implementation serverInfo, - @JsonProperty("instructions") String instructions) { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ClientCapabilities( // @formatter:off - @JsonProperty("experimental") Map experimental, - @JsonProperty("roots") RootCapabilities roots, - @JsonProperty("sampling") Object sampling) { - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record RootCapabilities( - @JsonProperty("listChanged") Boolean listChanged) { - } - }// @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ServerCapabilities( // @formatter:off - @JsonProperty("experimental") Map experimental, - @JsonProperty("logging") Object logging, - @JsonProperty("prompts") PromptCapabilities prompts, - @JsonProperty("resources") ResourceCapabilities resources, - @JsonProperty("tools") ToolCapabilities tools) { - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record PromptCapabilities( - @JsonProperty("listChanged") Boolean listChanged) { - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ResourceCapabilities( - @JsonProperty("subscribe") Boolean subscribe, - @JsonProperty("listChanged") Boolean listChanged) { - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ToolCapabilities( - @JsonProperty("listChanged") Boolean listChanged) { - } - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record Implementation(// @formatter:off - @JsonProperty("name") String name, - @JsonProperty("version") String version) { - } // @formatter:on - - // Existing Enums and Base Types (from previous implementation) - public enum Role { - - USER, ASSISTANT - - } - - public enum LoggingLevel { - - DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY - - } - - // --------------------------- - // Resource Interfaces - // --------------------------- - /** - * Base for objects that include optional annotations for the client. The client can - * use annotations to inform how objects are used or displayed - */ - public interface Annotated { - - Annotations annotations(); - - } - - /** - * @param audience Describes who the intended customer of this object or data is. It - * can include multiple entries to indicate content useful for multiple audiences - * (e.g., `["user", "assistant"]`). - * @param priority Describes how important this data is for operating the server. A - * value of 1 means "most important," and indicates that the data is effectively - * required, while 0 means "least important," and indicates that the data is entirely - * optional. It is a number between 0 and 1. - */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record Annotations( // @formatter:off - @JsonProperty("audience") List audience, - @JsonProperty("priority") Double priority) { - } // @formatter:on - - /** - * A known resource that the server is capable of reading. - * - * @param uri the URI of the resource. - * @param name A human-readable name for this resource. This can be used by clients to - * populate UI elements. - * @param description A description of what this resource represents. This can be used - * by clients to improve the LLM's understanding of available resources. It can be - * thought of like a "hint" to the model. - * @param mimeType The MIME type of this resource, if known. - */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - @JsonIgnoreProperties(ignoreUnknown = true) - public record Resource( // @formatter:off - @JsonProperty("uri") String uri, - @JsonProperty("name") String name, - @JsonProperty("description") String description, - @JsonProperty("mimeType") String mimeType, - @JsonProperty("annotations") Annotations annotations) implements Annotated { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ResourceTemplate( // @formatter:off - @JsonProperty("uriTemplate") String uriTemplate, - @JsonProperty("name") String name, - @JsonProperty("description") String description, - @JsonProperty("mimeType") String mimeType, - @JsonProperty("annotations") Annotations annotations) implements Annotated { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ListResourcesResult( // @formatter:off - @JsonProperty("resources") List resources, - @JsonProperty("nextCursor") String nextCursor) { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ListResourceTemplatesResult( // @formatter:off - @JsonProperty("resourceTemplates") List resourceTemplates, - @JsonProperty("nextCursor") String nextCursor) { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ReadResourceRequest( // @formatter:off - @JsonProperty("uri") String uri){ - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ReadResourceResult( // @formatter:off - @JsonProperty("contents") List contents){ - } // @formatter:on - - /** - * Sent from the client to request resources/updated notifications from the server - * whenever a particular resource changes. - * - * @param uri the URI of the resource to subscribe to. The URI can use any protocol; - * it is up to the server how to interpret it. - */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record SubscribeRequest( // @formatter:off - @JsonProperty("uri") String uri){ - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record UnsubscribeRequest( // @formatter:off - @JsonProperty("uri") String uri){ - } // @formatter:on - - /** - * The contents of a specific resource or sub-resource. - */ - @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, include = As.PROPERTY) - @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class, name = "text"), - @JsonSubTypes.Type(value = BlobResourceContents.class, name = "blob") }) - public sealed interface ResourceContents permits TextResourceContents, BlobResourceContents { - - /** - * The URI of this resource. - * @return the URI of this resource. - */ - String uri(); - - /** - * The MIME type of this resource. - * @return the MIME type of this resource. - */ - String mimeType(); - - } - - /** - * Text contents of a resource. - * - * @param uri the URI of this resource. - * @param mimeType the MIME type of this resource. - * @param text the text of the resource. This must only be set if the resource can - * actually be represented as text (not binary data). - */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record TextResourceContents( // @formatter:off - @JsonProperty("uri") String uri, - @JsonProperty("mimeType") String mimeType, - @JsonProperty("text") String text) implements ResourceContents { - } // @formatter:on - - /** - * Binary contents of a resource. - * - * @param uri the URI of this resource. - * @param mimeType the MIME type of this resource. - * @param blob a base64-encoded string representing the binary data of the resource. - * This must only be set if the resource can actually be represented as binary data - * (not text). - */ - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record BlobResourceContents( // @formatter:off - @JsonProperty("uri") String uri, - @JsonProperty("mimeType") String mimeType, - @JsonProperty("blob") String blob) implements ResourceContents { - } // @formatter:on - - // --------------------------- - // Prompt Interfaces - // --------------------------- - public record Prompt(String name, String description, List arguments) { - } - - public record PromptArgument(String name, String description, Boolean required) { - } - - public record PromptMessage(Role role, Content content) { - } - - // --------------------------- - // Tool Interfaces - // --------------------------- - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ListToolsResult( // @formatter:off - @JsonProperty("tools") List tools, - @JsonProperty("nextCursor") String nextCursor) { - }// @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record Tool( // @formatter:off - @JsonProperty("name") String name, - @JsonProperty("description") String description, - @JsonProperty("inputSchema") Map inputSchema) { - } // @formatter:on - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record CallToolRequest(// @formatter:off - @JsonProperty("name") String name, - @JsonProperty("arguments") Map arguments) implements Request { - }// @formatter:off - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record CallToolResult( // @formatter:off - @JsonProperty("content") List content, - @JsonProperty("isError") Boolean isError) { - } // @formatter:on - - // --------------------------- - // Sampling Interfaces - // --------------------------- - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record SamplingMessage(// @formatter:off - @JsonProperty("role") Role role, - @JsonProperty("content") Content content) { - } // @formatter:on - - // Sampling and Message Creation - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record CreateMessageRequest(List messages, ModelPreferences modelPreferences, - String systemPrompt, ContextInclusionStrategy includeContext, Double temperature, int maxTokens, - List stopSequences, Map metadata) implements Request { - - public enum ContextInclusionStrategy { - - NONE, THIS_SERVER, ALL_SERVERS - - } - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record CreateMessageResult(Role role, Content content, String model, StopReason stopReason) { - public enum StopReason { - - END_TURN, STOP_SEQUENCE, MAX_TOKENS - - } - } - - // --------------------------- - // Model Preferences - // --------------------------- - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ModelPreferences(List hints, Double costPriority, Double speedPriority, - Double intelligencePriority) { - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ModelHint(String name) { - } - - // --------------------------- - // Pagination Interfaces - // --------------------------- - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record PaginatedRequest(@JsonProperty("cursor") String cursor) { - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record PaginatedResult(@JsonProperty("nextCursor") String nextCursor) { - } - - // --------------------------- - // Progress and Logging - // --------------------------- - public record ProgressNotification(String progressToken, double progress, Double total) { - } - - public record LoggingMessageNotification(LoggingLevel level, String logger, Object data) { - } - - // --------------------------- - // Autocomplete - // --------------------------- - public record CompleteRequest(PromptOrResourceReference ref, CompleteArgument argument) implements Request { - public sealed interface PromptOrResourceReference permits PromptReference, ResourceReference { - - String type(); - - } - - public record PromptReference(String type, String name) implements PromptOrResourceReference { - } - - public record ResourceReference(String type, String uri) implements PromptOrResourceReference { - } - - public record CompleteArgument(String name, String value) { - } - } - - public record CompleteResult(CompleteCompletion completion) { - public record CompleteCompletion(List values, Integer total, Boolean hasMore) { - } - } - - // --------------------------- - // Content Types - // --------------------------- - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") - @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), - @JsonSubTypes.Type(value = ImageContent.class, name = "image"), - @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource") }) - public sealed interface Content permits TextContent, ImageContent, EmbeddedResource { - - String type(); - - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record TextContent( // @formatter:off - @JsonProperty("audience") List audience, - @JsonProperty("priority") Double priority, - @JsonProperty("type") String type, - @JsonProperty("text") String text) implements Content { // @formatter:on - - public TextContent { - type = "text"; - } - - public String type() { - return type; - } - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record ImageContent( // @formatter:off - @JsonProperty("audience") List audience, - @JsonProperty("priority") Double priority, - @JsonProperty("type") String type, - @JsonProperty("data") String data, - @JsonProperty("mimeType") String mimeType) implements Content { // @formatter:on - - public ImageContent { - type = "image"; - } - - public String type() { - return type; - } - } - - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record EmbeddedResource( // @formatter:off - @JsonProperty("audience") List audience, - @JsonProperty("priority") Double priority, - @JsonProperty("type") String type, - @JsonProperty("resource") ResourceContents resource) implements Content { // @formatter:on - - public EmbeddedResource { - type = "resource"; - } - - public String type() { - return type; - } - } - - // Roots - @JsonInclude(JsonInclude.Include.NON_ABSENT) - public record Root( // @formatter:off - @JsonProperty("uri") String uri, - @JsonProperty("name") String name) { - } // @formatter:on - -} \ No newline at end of file diff --git a/spring/src/main/java/spring/ai/mcp/spec/McpSession.java b/spring/src/main/java/spring/ai/mcp/spec/McpSession.java deleted file mode 100644 index 9cfbe11..0000000 --- a/spring/src/main/java/spring/ai/mcp/spec/McpSession.java +++ /dev/null @@ -1,160 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import java.time.Duration; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; -import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; -import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; - -import org.springframework.util.Assert; - -public class McpSession implements AutoCloseable { - - final ConcurrentHashMap> waitingForResponseStreams; - - private final Duration readTimeout; - - private final ObjectMapper objectMapper; - - private final McpTransport transport; - - public McpSession(McpTransport transport, Duration readTimeout, ObjectMapper objectMapper) { - - Assert.notNull(transport, "Transport must not be null"); - Assert.notNull(readTimeout, "Read timeout must not be null"); - Assert.notNull(objectMapper, "Object mapper must not be null"); - - this.waitingForResponseStreams = new ConcurrentHashMap<>(); - - this.readTimeout = readTimeout; - this.objectMapper = objectMapper; - - this.transport = transport; - - this.transport.setMessageHandler(message -> { - if (message instanceof JSONRPCResponse jsonRpcResponse) { - CompletableFuture responseFuture = this.waitingForResponseStreams - .remove(jsonRpcResponse.id()); - if (responseFuture != null) { - responseFuture.complete(jsonRpcResponse); - } - } - else if (message instanceof JSONRPCRequest jsonRpcRequest) { - handleRequest(new RequestResponder<>(jsonRpcRequest.id(), jsonRpcRequest)); - } - else if (message instanceof JSONRPCNotification jsonRpcNotification) { - handleNotification(jsonRpcNotification); - } - }); - - this.transport.setErrorHandler(error -> { - System.out.println("Received error: " + error); - }); - - this.transport.start(); - } - - public CompletableFuture sendRequest(String method, Object requestParams, TypeReference typeRef) { - - String requestId = UUID.randomUUID().toString(); - CompletableFuture responseFuture = new CompletableFuture<>(); - this.waitingForResponseStreams.put(requestId, responseFuture); - - JSONRPCRequest jsonrpcRequest = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, requestId, requestParams); - - try { - this.transport.sendMessage(jsonrpcRequest); - } - catch (Exception e) { - return CompletableFuture.failedFuture(new McpError(e)); - } - - return responseFuture.orTimeout(this.readTimeout.toMillis(), TimeUnit.MILLISECONDS) - .thenApply(jsonRpcResponse -> { - if (jsonRpcResponse.error() != null) { - throw new McpError(jsonRpcResponse.error()); - } - - return this.objectMapper.convertValue(jsonRpcResponse.result(), typeRef); - }); - } - - public CompletableFuture sendNotification(String method) { - return sendNotification(method, null); - } - - public CompletableFuture sendNotification(String method, Map params) { - JSONRPCNotification jsonrpcNotification = new JSONRPCNotification(McpSchema.JSONRPC_VERSION, method, params); - try { - this.transport.sendMessage(jsonrpcNotification); - } - catch (Exception e) { - return CompletableFuture.failedFuture(new McpError(e)); - } - return CompletableFuture.completedFuture(null); - } - - protected void handleRequest(RequestResponder responder) { - // Override to handle requests - System.out.println("Handling request: " + responder); - } - - protected void handleNotification(JSONRPCNotification notification) { - // Override to handle notifications - System.out.println("Handling notification: " + notification); - } - - @Override - public void close() { - this.transport.close(); - } - - public static class RequestResponder { - - private final Object requestId; - - private final R request; - - public RequestResponder(Object requestId, R request) { - this.requestId = requestId; - this.request = request; - } - - public CompletableFuture respond(Object response) { - // Send response - return CompletableFuture.completedFuture(null); - } - - } - - public static class McpError extends RuntimeException { - - public McpError(Object error) { - super(error.toString()); - } - - } - -} diff --git a/spring/src/main/java/spring/ai/mcp/spec/McpTransport.java b/spring/src/main/java/spring/ai/mcp/spec/McpTransport.java deleted file mode 100644 index 819ef5c..0000000 --- a/spring/src/main/java/spring/ai/mcp/spec/McpTransport.java +++ /dev/null @@ -1,38 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import java.util.function.Consumer; - -import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; - -/** - * @author Christian Tzolov - * @since 1.0.0 - */ - -public interface McpTransport extends AutoCloseable{ - - void start(); - - void close(); - - void setMessageHandler(Consumer messageReader); - - void setErrorHandler(Consumer errorReader); - - void sendMessage(JSONRPCMessage message); -} diff --git a/spring/src/test/java/spring/ai/mcp/McpAsyncClientTests.java b/spring/src/test/java/spring/ai/mcp/McpAsyncClientTests.java deleted file mode 100644 index 4629477..0000000 --- a/spring/src/test/java/spring/ai/mcp/McpAsyncClientTests.java +++ /dev/null @@ -1,151 +0,0 @@ -/* - * 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 spring.ai.mcp; - -import java.time.Duration; -import java.util.Map; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import spring.ai.mcp.client.McpAsyncClient; -import spring.ai.mcp.client.McpClient; -import spring.ai.mcp.client.stdio.StdioServerParameters; -import spring.ai.mcp.client.stdio.StdioServerTransport; -import spring.ai.mcp.spec.McpSchema.CallToolRequest; -import spring.ai.mcp.spec.McpSchema.Resource; -import spring.ai.mcp.spec.McpSchema.Tool; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Unit tests for MCP Client Session functionality. - * - * @author Christian Tzolov - * @since 1.0.0 - */ -class McpAsyncClientTests { - - private McpAsyncClient mcpAsyncClient; - - private StdioServerParameters stdioParams; - - private static final Duration TIMEOUT = Duration.ofSeconds(10); - - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; - - @BeforeEach - void setUp() { - stdioParams = StdioServerParameters.builder("npx") - .args("-y", "@modelcontextprotocol/server-everything", "dir") - .build(); - - assertThatCode(() -> { - mcpAsyncClient = McpClient.async(new StdioServerTransport(stdioParams), TIMEOUT, new ObjectMapper()); - mcpAsyncClient.initialize(); - }).doesNotThrowAnyException(); - } - - @AfterEach - void tearDown() { - // if (mcpSyncClient != null) { - // assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException(); - // } - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.sync(null, TIMEOUT, new ObjectMapper())) - .isInstanceOf(IllegalArgumentException.class); - - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), null, new ObjectMapper())) - .isInstanceOf(IllegalArgumentException.class); - - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), TIMEOUT, null)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @Timeout(15) // Giving extra time beyond the client timeout - void testListTools() { - mcpAsyncClient.listTools(null).subscribe(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }); - } - - @Test - @Timeout(15) - void testPing() { - assertThatCode(() -> mcpAsyncClient.ping().block()).doesNotThrowAnyException(); - } - - @Test - @Timeout(15) - void testCallTool() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - - mcpAsyncClient.callTool(callToolRequest).subscribe(callToolResult -> { - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - }); - } - - @Test - @Timeout(15) - void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); - - assertThatThrownBy(() -> mcpAsyncClient.callTool(invalidRequest).block()).isInstanceOf(Exception.class); - } - - @Test - @Timeout(15) - void testRootsListChanged() { - assertThatCode(() -> mcpAsyncClient.sendRootsListChanged().block()).doesNotThrowAnyException(); - } - - @Test - @Timeout(15) - void testListResources() { - mcpAsyncClient.listResources(null).subscribe(resources -> { - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - }); - } - - @Test - void testMcpAsyncClientState() { - assertThat(mcpAsyncClient).isNotNull(); - } - -} diff --git a/spring/src/test/java/spring/ai/mcp/McpSyncClientTests.java b/spring/src/test/java/spring/ai/mcp/McpSyncClientTests.java deleted file mode 100644 index 3b5a9f2..0000000 --- a/spring/src/test/java/spring/ai/mcp/McpSyncClientTests.java +++ /dev/null @@ -1,158 +0,0 @@ -/* - * 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 spring.ai.mcp; - -import java.time.Duration; -import java.util.Map; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import static org.assertj.core.api.Assertions.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import spring.ai.mcp.client.McpClient; -import spring.ai.mcp.client.McpSyncClient; -import spring.ai.mcp.client.stdio.StdioServerParameters; -import spring.ai.mcp.client.stdio.StdioServerTransport; -import spring.ai.mcp.spec.McpSchema.CallToolRequest; -import spring.ai.mcp.spec.McpSchema.CallToolResult; -import spring.ai.mcp.spec.McpSchema.ListResourcesResult; -import spring.ai.mcp.spec.McpSchema.ListToolsResult; -import spring.ai.mcp.spec.McpSchema.Tool; -import spring.ai.mcp.spec.McpSchema.Resource; - -/** - * Unit tests for MCP Client Session functionality. - * - * @author Christian Tzolov - * @since 1.0.0 - */ -class McpSyncClientTests { - - private McpSyncClient mcpSyncClient; - - private StdioServerParameters stdioParams; - - private static final Duration TIMEOUT = Duration.ofSeconds(10); - - private static final String TEST_MESSAGE = "Hello MCP Spring AI!"; - - @BeforeEach - void setUp() { - stdioParams = StdioServerParameters.builder("npx") - .args("-y", "@modelcontextprotocol/server-everything", "dir") - .build(); - - assertThatCode(() -> { - mcpSyncClient = McpClient.sync(new StdioServerTransport(stdioParams), TIMEOUT, new ObjectMapper()); - mcpSyncClient.initialize(); - }).doesNotThrowAnyException(); - } - - @AfterEach - void tearDown() { - if (mcpSyncClient != null) { - assertThatCode(() -> mcpSyncClient.close()).doesNotThrowAnyException(); - } - } - - @Test - void testConstructorWithInvalidArguments() { - assertThatThrownBy(() -> McpClient.sync(null, TIMEOUT, new ObjectMapper())) - .isInstanceOf(IllegalArgumentException.class); - - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), null, new ObjectMapper())) - .isInstanceOf(IllegalArgumentException.class); - - assertThatThrownBy(() -> McpClient.sync(new StdioServerTransport(stdioParams), TIMEOUT, null)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - @Timeout(15) // Giving extra time beyond the client timeout - void testListTools() { - ListToolsResult tools = mcpSyncClient.listTools(null); - - assertThat(tools).isNotNull().satisfies(result -> { - assertThat(result.tools()).isNotNull().isNotEmpty(); - - Tool firstTool = result.tools().get(0); - assertThat(firstTool.name()).isNotNull(); - assertThat(firstTool.description()).isNotNull(); - }); - } - - @Test - @Timeout(15) - void testPing() { - assertThatCode(() -> mcpSyncClient.ping()).doesNotThrowAnyException(); - } - - @Test - @Timeout(15) - void testCallTool() { - CallToolRequest callToolRequest = new CallToolRequest("echo", Map.of("message", TEST_MESSAGE)); - - CallToolResult callToolResult = mcpSyncClient.callTool(callToolRequest); - - assertThat(callToolResult).isNotNull().satisfies(result -> { - assertThat(result.content()).isNotNull(); - assertThat(result.isError()).isNull(); - }); - } - - @Test - @Timeout(15) - void testCallToolWithInvalidTool() { - CallToolRequest invalidRequest = new CallToolRequest("nonexistent_tool", Map.of("message", TEST_MESSAGE)); - - assertThatThrownBy(() -> mcpSyncClient.callTool(invalidRequest)).isInstanceOf(Exception.class); - } - - @Test - @Timeout(15) - void testRootsListChanged() { - assertThatCode(() -> mcpSyncClient.sendRootsListChanged()).doesNotThrowAnyException(); - } - - @Test - @Timeout(15) - void testListResources() { - ListResourcesResult resources = mcpSyncClient.listResources(null); - - assertThat(resources).isNotNull().satisfies(result -> { - assertThat(result.resources()).isNotNull(); - - if (!result.resources().isEmpty()) { - Resource firstResource = result.resources().get(0); - assertThat(firstResource.uri()).isNotNull(); - assertThat(firstResource.name()).isNotNull(); - } - }); - } - - @Test - void testClientSessionState() { - assertThat(mcpSyncClient).isNotNull(); - } - -} diff --git a/spring/src/test/java/spring/ai/mcp/attic/ClientSessionTests2.java b/spring/src/test/java/spring/ai/mcp/attic/ClientSessionTests2.java deleted file mode 100644 index 578fa3d..0000000 --- a/spring/src/test/java/spring/ai/mcp/attic/ClientSessionTests2.java +++ /dev/null @@ -1,80 +0,0 @@ -/* -* 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 spring.ai.mcp.attic; - -import java.time.Duration; - -import com.fasterxml.jackson.databind.ObjectMapper; -import spring.ai.mcp.client.McpClient; -import spring.ai.mcp.client.McpSyncClient; -import spring.ai.mcp.client.stdio.StdioServerParameters; -import spring.ai.mcp.client.stdio.StdioServerTransport; -import spring.ai.mcp.spec.McpSchema.ListResourcesResult; -import spring.ai.mcp.spec.McpSchema.ListToolsResult; -import spring.ai.mcp.spec.McpSchema.Resource; - -/** - * @author Christian Tzolov - * @since 1.0.0 - */ -public class ClientSessionTests2 { - - public static void main(String[] args) { - - var stdioParams = StdioServerParameters.builder("uv") - .args("--directory", "dir", "run", "mcp-server-sqlite", - "--db-path", "~/test.db") - .build(); - - try (McpSyncClient clientSession = - McpClient.sync(new StdioServerTransport(stdioParams), - Duration.ofSeconds(10), new ObjectMapper())) { - - clientSession.initialize(); - - ListToolsResult tools = clientSession.listTools(null); - System.out.println("Tools: " + tools); - - clientSession.ping(); - - // CallToolRequest callToolRequest = new CallToolRequest("echo", - // Map.of("message", "Hello MCP Spring AI!")); - // CallToolResult callToolResult = - // clientSession.callTool(callToolRequest); - // System.out.println("Call Tool Result: " + callToolResult); - // // - // clientSession.sendRootsListChanged(); - - // Resources - ListResourcesResult resources = clientSession.listResources(null); - System.out.println("Resources Size: " + resources.resources().size()); - System.out.println("Resources: " + resources); - for (Resource resource : resources.resources()) { - System.out.println(clientSession.readResource(resource)); - - } - - // var resourceTemplate = clientSession.listResourceTemplates(null); - // System.out.println("Resource Templates: " + resourceTemplate); - - // stdioClient.awaitForExit(); - } - catch (Exception e) { - e.printStackTrace(); - } - } - -} diff --git a/spring/src/test/java/spring/ai/mcp/attic/ClientSessionTests3.java b/spring/src/test/java/spring/ai/mcp/attic/ClientSessionTests3.java deleted file mode 100644 index e080dd6..0000000 --- a/spring/src/test/java/spring/ai/mcp/attic/ClientSessionTests3.java +++ /dev/null @@ -1,75 +0,0 @@ -/* -* 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 spring.ai.mcp.attic; - -import java.time.Duration; - -import com.fasterxml.jackson.databind.ObjectMapper; -import spring.ai.mcp.client.McpClient; -import spring.ai.mcp.client.McpSyncClient; -import spring.ai.mcp.client.stdio.StdioServerParameters; -import spring.ai.mcp.client.stdio.StdioServerTransport; -import spring.ai.mcp.spec.McpSchema.ListResourcesResult; -import spring.ai.mcp.spec.McpSchema.ListToolsResult; -import spring.ai.mcp.spec.McpSchema.Resource; - -/** - * @author Christian Tzolov - * @since 1.0.0 - */ -public class ClientSessionTests3 { - - public static void main(String[] args) { - - var stdioParams = StdioServerParameters.builder("uv") - .args("--directory", "dir", "run", "mcp-server-sqlite", - "--db-path", "~/test.db") - .build(); - - - McpSyncClient clientSession = null; - try { - - clientSession = McpClient.sync(new StdioServerTransport(stdioParams), - Duration.ofSeconds(10), new ObjectMapper()); - - clientSession.initialize(); - - ListToolsResult tools = clientSession.listTools(null); - System.out.println("Tools: " + tools); - - clientSession.ping(); - - // Resources - ListResourcesResult resources = clientSession.listResources(null); - System.out.println("Resources Size: " + resources.resources().size()); - System.out.println("Resources: " + resources); - for (Resource resource : resources.resources()) { - System.out.println(clientSession.readResource(resource)); - - } - } - catch (Exception e) { - e.printStackTrace(); - } - finally { - if (clientSession != null) { - clientSession.close(); - } - } - } - -} diff --git a/spring/src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java b/spring/src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java deleted file mode 100644 index 3a56a89..0000000 --- a/spring/src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java +++ /dev/null @@ -1,158 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; -import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; -import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; -import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; - -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class McpSessionJSONRPCTest { - - private McpTransport mockTransport; - private ObjectMapper objectMapper; - private McpSession mcpSession; - private ArgumentCaptor> messageHandlerCaptor; - - @BeforeEach - void setUp() { - mockTransport = mock(McpTransport.class); - objectMapper = new ObjectMapper(); - mcpSession = new McpSession(mockTransport, Duration.ofSeconds(5), objectMapper); - - // Capture the message handler set during the session initialization - messageHandlerCaptor = ArgumentCaptor.forClass(Consumer.class); - verify(mockTransport).setMessageHandler(messageHandlerCaptor.capture()); - } - - @Test - void sendRequestShouldSendValidRequestMessage() { - String method = "testMethod"; - Map params = Map.of("key", "value"); - - // Call sendRequest - CompletableFuture future = mcpSession.sendRequest(method, params, new TypeReference<>() {}); - - // Capture the JSONRPCRequest sent to the transport - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); - verify(mockTransport).sendMessage(messageCaptor.capture()); - - // Assert the message details - JSONRPCRequest request = (JSONRPCRequest) messageCaptor.getValue(); - assertEquals(method, request.method()); - assertEquals("value", ((Map) request.params()).get("key")); - assertNotNull(request.id()); - } - - @Test - void sendNotificationShouldSendValidNotificationMessage() { - String method = "notifyMethod"; - Map params = Map.of("param1", "value1"); - - // Call sendNotification - mcpSession.sendNotification(method, params); - - // Capture the JSONRPCNotification sent to the transport - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); - verify(mockTransport).sendMessage(messageCaptor.capture()); - - // Assert the notification details - JSONRPCNotification notification = (JSONRPCNotification) messageCaptor.getValue(); - assertEquals(method, notification.method()); - assertEquals("value1", ((Map) notification.params()).get("param1")); - } - - @Test - void messageHandlerShouldProcessResponseCorrectly() { - // Retrieve the captured message handler - Consumer messageHandler = messageHandlerCaptor.getValue(); - - // Simulate a JSONRPCResponse - JSONRPCResponse response = new JSONRPCResponse("2.0", "1", "resultValue", null); - CompletableFuture responseFuture = new CompletableFuture<>(); - mcpSession.waitingForResponseStreams.put("1", responseFuture); - - // Trigger the handler with the response - messageHandler.accept(response); - - // Verify the response is completed - assertTrue(responseFuture.isDone()); - assertEquals("resultValue", responseFuture.join().result()); - } - - @Test - @Disabled - void messageHandlerShouldInvokeHandleRequestForRequestMessage() { - // Retrieve the captured message handler - Consumer messageHandler = messageHandlerCaptor.getValue(); - - // Mock request data - JSONRPCRequest request = new JSONRPCRequest("2.0", "testMethod", "1", Map.of("key", "value")); - - // Override the handleRequest to check invocation - McpSession spySession = spy(mcpSession); - doNothing().when(spySession).handleRequest(any()); - - // Trigger the handler with a request - messageHandler.accept(request); - - // Verify handleRequest was called - verify(spySession).handleRequest(any()); - } - - @Test - @Disabled - void messageHandlerShouldInvokeHandleNotificationForNotificationMessage() { - // Retrieve the captured message handler - Consumer messageHandler = messageHandlerCaptor.getValue(); - - // Mock notification data - JSONRPCNotification notification = new JSONRPCNotification("2.0", "notifyMethod", Map.of("key", "value")); - - // Override the handleNotification to check invocation - McpSession spySession = spy(mcpSession); - doNothing().when(spySession).handleNotification(any()); - - // Trigger the handler with a notification - messageHandler.accept(notification); - - // Verify handleNotification was called - verify(spySession).handleNotification(notification); - } - - @Test - void closeShouldInvokeTransportClose() { - // Call close on the session - mcpSession.close(); - - // Verify transport close was called - verify(mockTransport).close(); - } -} diff --git a/spring/src/test/java/spring/ai/mcp/spec/McpSessionTest.java b/spring/src/test/java/spring/ai/mcp/spec/McpSessionTest.java deleted file mode 100644 index 2826b60..0000000 --- a/spring/src/test/java/spring/ai/mcp/spec/McpSessionTest.java +++ /dev/null @@ -1,93 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class McpSessionTest { - - private McpTransport mockTransport; - - private ObjectMapper mockObjectMapper; - - private McpSession mcpSession; - - @BeforeEach - void setUp() { - mockTransport = mock(McpTransport.class); - mockObjectMapper = mock(ObjectMapper.class); - mcpSession = new McpSession(mockTransport, Duration.ofSeconds(5), mockObjectMapper); - } - - @Test - void constructorShouldThrowIfArgumentsAreNull() { - assertThrows(IllegalArgumentException.class, - () -> new McpSession(null, Duration.ofSeconds(5), mockObjectMapper)); - assertThrows(IllegalArgumentException.class, () -> new McpSession(mockTransport, null, mockObjectMapper)); - assertThrows(IllegalArgumentException.class, () -> new McpSession(mockTransport, Duration.ofSeconds(5), null)); - } - - @Test - @Disabled - void sendRequestShouldSendJsonRpcRequest() throws Exception { - String method = "testMethod"; - Object params = Map.of("key", "value"); - TypeReference typeRef = new TypeReference<>() { - }; - - CompletableFuture mockResponseFuture = new CompletableFuture<>(); - mockResponseFuture.complete(new JSONRPCResponse("2.0", "1", "result", null)); - // when(mockTransport.sendMessage(any())).then(invocation -> null); - when(mockObjectMapper.convertValue("result", typeRef)).thenReturn("result"); - - CompletableFuture result = mcpSession.sendRequest(method, params, typeRef); - assertEquals("result", result.get()); - } - - @Test - void sendRequestShouldHandleTimeout() { - String method = "timeoutMethod"; - Object params = Map.of("key", "value"); - TypeReference typeRef = new TypeReference<>() { - }; - - CompletableFuture result = mcpSession.sendRequest(method, params, typeRef); - assertThrows(Exception.class, result::get); - } - - @Test - void closeShouldInvokeTransportClose() { - mcpSession.close(); - verify(mockTransport, times(1)).close(); - } - -} diff --git a/spring/src/test/java/spring/ai/mcp/spec/McpTransportTest.java b/spring/src/test/java/spring/ai/mcp/spec/McpTransportTest.java deleted file mode 100644 index 07be6fd..0000000 --- a/spring/src/test/java/spring/ai/mcp/spec/McpTransportTest.java +++ /dev/null @@ -1,128 +0,0 @@ -/* -* 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 spring.ai.mcp.spec; - - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; -import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; -import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; -import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; - -import java.time.Duration; -import java.util.Map; -import java.util.function.Consumer; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class McpTransportTest { - - private McpTransport mockTransport; - private McpSession mcpSession; - - @BeforeEach - void setUp() { - mockTransport = mock(McpTransport.class); - mcpSession = new McpSession(mockTransport, Duration.ofSeconds(5), new ObjectMapper()); - } - - @Test - void constructorShouldInitializeTransport() { - // Verify transport setup during construction - verify(mockTransport).setMessageHandler(any(Consumer.class)); - verify(mockTransport).setErrorHandler(any(Consumer.class)); - verify(mockTransport).start(); - } - - @Test - void sendRequestShouldCallTransportSendMessage() { - String method = "testMethod"; - Object params = Map.of("key", "value"); - - // Call sendRequest - mcpSession.sendRequest(method, params, new TypeReference() {}); - - // Capture the message sent to transport - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); - verify(mockTransport).sendMessage(messageCaptor.capture()); - - // Verify the message details - JSONRPCRequest request = (JSONRPCRequest) messageCaptor.getValue(); - assertEquals("testMethod", request.method()); - assertEquals("value", ((Map) request.params()).get("key")); - assertNotNull(request.id()); - } - - @Test - void sendNotificationShouldCallTransportSendMessage() { - String method = "notifyMethod"; - Map params = Map.of("param1", "value1"); - - // Call sendNotification - mcpSession.sendNotification(method, params); - - // Capture the notification sent to transport - ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); - verify(mockTransport).sendMessage(messageCaptor.capture()); - - // Verify the notification details - JSONRPCNotification notification = (JSONRPCNotification) messageCaptor.getValue(); - assertEquals("notifyMethod", notification.method()); - assertEquals("value1", ((Map) notification.params()).get("param1")); - } - - @Test - void messageHandlerShouldProcessResponse() { - // Capture the message handler during initialization - ArgumentCaptor> handlerCaptor = ArgumentCaptor.forClass(Consumer.class); - verify(mockTransport).setMessageHandler(handlerCaptor.capture()); - Consumer handler = handlerCaptor.getValue(); - - // Simulate a JSONRPCResponse - JSONRPCResponse response = new JSONRPCResponse("2.0", "1", "result", null); - handler.accept(response); - - // Verify the response was processed - // (Use CompletableFuture to match the response if needed in real tests) - } - - @Test - void errorHandlerShouldLogErrors() { - // Capture the error handler during initialization - ArgumentCaptor> handlerCaptor = ArgumentCaptor.forClass(Consumer.class); - verify(mockTransport).setErrorHandler(handlerCaptor.capture()); - Consumer errorHandler = handlerCaptor.getValue(); - - // Simulate an error - String errorMessage = "Test error"; - errorHandler.accept(errorMessage); - - // Verify the error was logged or processed - // (In real tests, capture logging output or other side effects) - } - - @Test - void closeShouldStopTransport() { - mcpSession.close(); - verify(mockTransport).close(); - } -}