non-blocking sinks processing on dedicated Schedulers
This commit is contained in:
parent
d21565a559
commit
076c214400
6 changed files with 122 additions and 73 deletions
|
|
@ -1,22 +1,20 @@
|
||||||
package spring.ai.mcp.client;
|
package spring.ai.mcp.client;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import spring.ai.mcp.spec.McpAsyncTransport;
|
||||||
import spring.ai.mcp.spec.McpSchema;
|
import spring.ai.mcp.spec.McpSchema;
|
||||||
import spring.ai.mcp.spec.McpSession;
|
|
||||||
import spring.ai.mcp.spec.McpTransport;
|
|
||||||
|
|
||||||
public class McpAsyncClient extends McpAsyncSession {
|
public class McpAsyncClient extends McpAsyncSession {
|
||||||
|
|
||||||
public McpAsyncClient(McpTransport transport) {
|
public McpAsyncClient(McpAsyncTransport transport) {
|
||||||
this(transport, Duration.ofSeconds(10), new ObjectMapper());
|
this(transport, Duration.ofSeconds(10), new ObjectMapper());
|
||||||
}
|
}
|
||||||
|
|
||||||
public McpAsyncClient(McpTransport transport, Duration requestTimeout, ObjectMapper objectMapper) {
|
public McpAsyncClient(McpAsyncTransport transport, Duration requestTimeout, ObjectMapper objectMapper) {
|
||||||
super(requestTimeout, objectMapper, transport);
|
super(requestTimeout, objectMapper, transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,15 @@ package spring.ai.mcp.client;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.publisher.MonoSink;
|
import reactor.core.publisher.MonoSink;
|
||||||
import reactor.core.publisher.Sinks;
|
import spring.ai.mcp.spec.McpAsyncTransport;
|
||||||
import spring.ai.mcp.spec.McpSchema;
|
|
||||||
import spring.ai.mcp.spec.McpSession;
|
|
||||||
import spring.ai.mcp.spec.McpTransport;
|
import spring.ai.mcp.spec.McpTransport;
|
||||||
|
import spring.ai.mcp.spec.McpSchema;
|
||||||
|
|
||||||
public class McpAsyncSession {
|
public class McpAsyncSession {
|
||||||
|
|
||||||
|
|
@ -25,11 +22,11 @@ public class McpAsyncSession {
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
private final McpTransport transport;
|
private final McpAsyncTransport transport;
|
||||||
|
|
||||||
public McpAsyncSession(Duration requestTimeout,
|
public McpAsyncSession(Duration requestTimeout,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
McpTransport transport) {
|
McpAsyncTransport transport) {
|
||||||
this.requestTimeout = requestTimeout;
|
this.requestTimeout = requestTimeout;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
|
|
@ -65,8 +62,15 @@ public class McpAsyncSession {
|
||||||
this.pendingResponses.put(requestId, sink);
|
this.pendingResponses.put(requestId, sink);
|
||||||
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, requestId, requestParams);
|
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, requestId, requestParams);
|
||||||
try {
|
try {
|
||||||
// TODO convert the transport to non-blocking as well
|
// TODO: This is non-blocking, but it's actually a synchronous call,
|
||||||
this.transport.sendMessage(jsonrpcRequest);
|
// 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) {
|
} catch (Exception e) {
|
||||||
sink.error(e);
|
sink.error(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.McpSchema;
|
||||||
import spring.ai.mcp.spec.McpSession;
|
import spring.ai.mcp.spec.McpSession;
|
||||||
import spring.ai.mcp.spec.McpTransport;
|
import spring.ai.mcp.spec.McpTransport;
|
||||||
|
|
@ -59,20 +61,20 @@ public class McpClient {
|
||||||
// JSONtoPOJOCodec type
|
// JSONtoPOJOCodec type
|
||||||
// .sync();
|
// .sync();
|
||||||
|
|
||||||
public static McpAsyncClient async(McpTransport transport) {
|
public static McpAsyncClient async(McpAsyncTransport transport) {
|
||||||
return new McpAsyncClient(transport);
|
return new McpAsyncClient(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static McpAsyncClient async(McpTransport transport, Duration requestTimeout,
|
public static McpAsyncClient async(McpAsyncTransport transport, Duration requestTimeout,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
return new McpAsyncClient(transport, requestTimeout, objectMapper);
|
return new McpAsyncClient(transport, requestTimeout, objectMapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static McpSyncClient sync(McpTransport transport) {
|
public static McpSyncClient sync(McpAsyncTransport transport) {
|
||||||
return new McpSyncClient(async(transport));
|
return new McpSyncClient(async(transport));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static McpSyncClient sync(McpTransport transport, Duration requestTimeout,
|
public static McpSyncClient sync(McpAsyncTransport transport, Duration requestTimeout,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
return new McpSyncClient(async(transport, requestTimeout, objectMapper));
|
return new McpSyncClient(async(transport, requestTimeout, objectMapper));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import java.io.IOException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
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.DefaultMcpTransport;
|
||||||
import spring.ai.mcp.spec.McpSchema.JSONRPCMessage;
|
import spring.ai.mcp.spec.McpSchema.JSONRPCMessage;
|
||||||
import spring.ai.mcp.spec.McpSchema.JSONRPCNotification;
|
import spring.ai.mcp.spec.McpSchema.JSONRPCNotification;
|
||||||
|
|
@ -31,7 +32,9 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
|
|
||||||
private BufferedWriter processWriter;
|
private BufferedWriter processWriter;
|
||||||
|
|
||||||
private ExecutorService executorService;
|
private Scheduler inboundScheduler;
|
||||||
|
private Scheduler outboundScheduler;
|
||||||
|
private Scheduler errorScheduler;
|
||||||
|
|
||||||
private volatile boolean isRunning;
|
private volatile boolean isRunning;
|
||||||
|
|
||||||
|
|
@ -49,7 +52,15 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
this.params = params;
|
this.params = params;
|
||||||
|
|
||||||
// Start threads
|
// Start threads
|
||||||
this.executorService = Executors.newFixedThreadPool(3);
|
this.inboundScheduler =
|
||||||
|
Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(),
|
||||||
|
"inbound");
|
||||||
|
this.outboundScheduler =
|
||||||
|
Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(),
|
||||||
|
"outbound");
|
||||||
|
this.errorScheduler =
|
||||||
|
Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(),
|
||||||
|
"error");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -83,9 +94,9 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
|
|
||||||
// Start threads
|
// Start threads
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
startReaderThread();
|
startInboundProcessing();
|
||||||
startWriterThread();
|
startOutboundProcessing();
|
||||||
startErrorReaderThread();
|
startErrorProcessing();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,9 +109,8 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startErrorReaderThread() {
|
private void startErrorProcessing() {
|
||||||
|
this.errorScheduler.schedule(() -> {
|
||||||
this.executorService.execute(() -> {
|
|
||||||
try {
|
try {
|
||||||
String line;
|
String line;
|
||||||
while (isRunning && processErrorReader != null && (line = processErrorReader.readLine()) != null) {
|
while (isRunning && processErrorReader != null && (line = processErrorReader.readLine()) != null) {
|
||||||
|
|
@ -143,15 +153,15 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText);
|
throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startReaderThread() {
|
private void startInboundProcessing() {
|
||||||
this.executorService.execute(() -> {
|
this.inboundScheduler.schedule(() -> {
|
||||||
try {
|
try {
|
||||||
String line;
|
String line;
|
||||||
while (this.isRunning && this.processReader != null && (line = this.processReader.readLine()) != null) {
|
while (this.isRunning && this.processReader != null && (line = this.processReader.readLine()) != null) {
|
||||||
try {
|
try {
|
||||||
JSONRPCMessage message = deserializeJsonRpcMessage(line);
|
JSONRPCMessage message = deserializeJsonRpcMessage(line);
|
||||||
if (!this.getInboundSink().tryEmitNext(message).isSuccess()) {
|
if (!this.getInboundSink().tryEmitNext(message).isSuccess()) {
|
||||||
// TODO: Back off, reschedule -> same comment as for writing
|
// TODO: Back off, reschedule, give up?
|
||||||
throw new RuntimeException("Failed to enqueue message");
|
throw new RuntimeException("Failed to enqueue message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,29 +181,33 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startWriterThread() {
|
private void startOutboundProcessing() {
|
||||||
this.executorService.execute(() -> {
|
this.getOutboundSink()
|
||||||
this.getOutboundSink().asFlux().handle((message, s) -> {
|
.asFlux()
|
||||||
if (message != null) {
|
// this bit is important since writes come from user threads and we
|
||||||
try {
|
// want to ensure that the actual writing happens on a dedicated thread
|
||||||
this.processWriter.write(ModelOptionsUtils.toJsonString(message));
|
.publishOn(outboundScheduler)
|
||||||
this.processWriter.newLine();
|
.handle((message, s) -> {
|
||||||
this.processWriter.flush();
|
if (message != null) {
|
||||||
s.next(message);
|
try {
|
||||||
} catch (IOException e) {
|
this.processWriter.write(ModelOptionsUtils.toJsonString(message));
|
||||||
s.error(new RuntimeException(e));
|
this.processWriter.newLine();
|
||||||
}
|
this.processWriter.flush();
|
||||||
}
|
s.next(message);
|
||||||
}).subscribe();
|
}
|
||||||
});
|
catch (IOException e) {
|
||||||
|
s.error(new RuntimeException(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: provide a non-blocking variant with graceful option
|
||||||
public void stop() {
|
public void stop() {
|
||||||
|
this.outboundScheduler.dispose();
|
||||||
this.isRunning = false;
|
this.inboundScheduler.dispose();
|
||||||
|
this.errorScheduler.dispose();
|
||||||
// Interrupt threads
|
|
||||||
this.executorService.shutdownNow();
|
|
||||||
|
|
||||||
// Destroy process
|
// Destroy process
|
||||||
if (this.process != null) {
|
if (this.process != null) {
|
||||||
|
|
@ -201,10 +215,6 @@ public class StdioServerTransport extends DefaultMcpTransport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isRunning() {
|
|
||||||
return this.isRunning;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
stop();
|
stop();
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,9 @@
|
||||||
package spring.ai.mcp.spec;
|
package spring.ai.mcp.spec;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.publisher.Sinks;
|
import reactor.core.publisher.Sinks;
|
||||||
import spring.ai.mcp.spec.McpSchema.JSONRPCMessage;
|
import spring.ai.mcp.spec.McpSchema.JSONRPCMessage;
|
||||||
|
|
||||||
|
|
@ -27,7 +26,7 @@ import spring.ai.mcp.spec.McpSchema.JSONRPCMessage;
|
||||||
* @author Christian Tzolov
|
* @author Christian Tzolov
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
public class DefaultMcpTransport implements McpTransport {
|
public class DefaultMcpTransport implements McpAsyncTransport {
|
||||||
|
|
||||||
private final Sinks.Many<String> errorSink;
|
private final Sinks.Many<String> errorSink;
|
||||||
|
|
||||||
|
|
@ -35,8 +34,6 @@ public class DefaultMcpTransport implements McpTransport {
|
||||||
|
|
||||||
private final Sinks.Many<JSONRPCMessage> outboundSink;
|
private final Sinks.Many<JSONRPCMessage> outboundSink;
|
||||||
|
|
||||||
private final ExecutorService executorService;
|
|
||||||
|
|
||||||
private final Duration writeTimeout;
|
private final Duration writeTimeout;
|
||||||
|
|
||||||
private Consumer<JSONRPCMessage> messageReader = message -> System.out.println("Received message: " + message);
|
private Consumer<JSONRPCMessage> messageReader = message -> System.out.println("Received message: " + message);
|
||||||
|
|
@ -56,23 +53,18 @@ public class DefaultMcpTransport implements McpTransport {
|
||||||
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
|
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
|
||||||
this.writeTimeout = readTimeout;
|
this.writeTimeout = readTimeout;
|
||||||
|
|
||||||
this.executorService = Executors.newFixedThreadPool(2);
|
|
||||||
this.handleIncomingMessages();
|
this.handleIncomingMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleIncomingMessages() {
|
private void handleIncomingMessages() {
|
||||||
this.executorService.execute(() -> {
|
this.inboundSink.asFlux()
|
||||||
this.inboundSink.asFlux()
|
.subscribe(message -> this.messageReader.accept(message));
|
||||||
.subscribe(message -> this.messageReader.accept(message));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleIncomingErrors() {
|
private void handleIncomingErrors() {
|
||||||
this.executorService.execute(() -> {
|
this.errorSink.asFlux().subscribe(e -> {
|
||||||
this.errorSink.asFlux().subscribe(e -> {
|
this.errorReader.accept(e);
|
||||||
this.errorReader.accept(e);
|
System.err.println(e);
|
||||||
System.err.println(e);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,15 +98,19 @@ public class DefaultMcpTransport implements McpTransport {
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
// this.onClose();
|
// this.onClose();
|
||||||
this.executorService.shutdownNow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendMessage(JSONRPCMessage message) {
|
@Override
|
||||||
if (!this.outboundSink.tryEmitNext(message).isSuccess()) {
|
public Mono<Void> sendMessage(JSONRPCMessage message) {
|
||||||
|
if (this.outboundSink.tryEmitNext(message).isSuccess()) {
|
||||||
// TODO: essentially we could reschedule ourselves in some time and make
|
// TODO: essentially we could reschedule ourselves in some time and make
|
||||||
// another attempt with the already read data but pause reading until
|
// another attempt with the already read data but pause reading until
|
||||||
// success
|
// success
|
||||||
throw new RuntimeException("Failed to enqueue message");
|
// 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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
src/main/java/spring/ai/mcp/spec/McpAsyncTransport.java
Normal file
39
src/main/java/spring/ai/mcp/spec/McpAsyncTransport.java
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 - 2024 the original author or authors.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package 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<JSONRPCMessage> messageReader);
|
||||||
|
|
||||||
|
void setErrorHandler(Consumer<String> errorReader);
|
||||||
|
|
||||||
|
Mono<Void> sendMessage(JSONRPCMessage message);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue