chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" 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">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-mcp</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-mcp-infra</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-mcp-spec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-mcp-adapter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-agent-core-sdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-compose-sdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-knowledge-sdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-spring-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-eco-market-sdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-pricing-sdk</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-application</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Flow;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* A Server-Sent Events (SSE) client implementation using Java's Flow API for reactive
|
||||
* stream processing. This client establishes a connection to an SSE endpoint and
|
||||
* processes the incoming event stream, parsing SSE-formatted messages into structured
|
||||
* events.
|
||||
*
|
||||
* <p>
|
||||
* The client supports standard SSE event fields including:
|
||||
* <ul>
|
||||
* <li>event - The event type (defaults to "message" if not specified)</li>
|
||||
* <li>id - The event ID</li>
|
||||
* <li>data - The event payload data</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Events are delivered to a provided {@link SseEventHandler} which can process events and
|
||||
* handle any errors that occur during the connection.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @see SseEventHandler
|
||||
* @see SseEvent
|
||||
*/
|
||||
@Slf4j
|
||||
public class FlowSseClient {
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private final HttpRequest.Builder requestBuilder;
|
||||
|
||||
private Flow.Subscription subscription;
|
||||
|
||||
private String url;
|
||||
|
||||
private CompletableFuture<HttpResponse<Void>> future;
|
||||
|
||||
/**
|
||||
* Pattern to extract the data content from SSE data field lines. Matches lines
|
||||
* starting with "data:" and captures the remaining content.
|
||||
*/
|
||||
private static final Pattern EVENT_DATA_PATTERN = Pattern.compile("^data:(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Pattern to extract the event ID from SSE id field lines. Matches lines starting
|
||||
* with "id:" and captures the ID value.
|
||||
*/
|
||||
private static final Pattern EVENT_ID_PATTERN = Pattern.compile("^id:(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Pattern to extract the event type from SSE event field lines. Matches lines
|
||||
* starting with "event:" and captures the event type.
|
||||
*/
|
||||
private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^event:(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Record class representing a Server-Sent Event with its standard fields.
|
||||
*
|
||||
* @param id the event ID (may be null)
|
||||
* @param type the event type (defaults to "message" if not specified in the stream)
|
||||
* @param data the event payload data
|
||||
*/
|
||||
public static record SseEvent(String id, String type, String data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for handling SSE events and errors. Implementations can process received
|
||||
* events and handle any errors that occur during the SSE connection.
|
||||
*/
|
||||
public interface SseEventHandler {
|
||||
|
||||
/**
|
||||
* Called when an SSE event is received.
|
||||
*
|
||||
* @param event the received SSE event containing id, type, and data
|
||||
*/
|
||||
void onEvent(SseEvent event);
|
||||
|
||||
/**
|
||||
* Called when an error occurs during the SSE connection.
|
||||
*
|
||||
* @param error the error that occurred
|
||||
*/
|
||||
void onError(Throwable error);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new FlowSseClient with the specified HTTP client.
|
||||
*
|
||||
* @param httpClient the {@link HttpClient} instance to use for SSE connections
|
||||
*/
|
||||
public FlowSseClient(HttpClient httpClient) {
|
||||
this(httpClient, HttpRequest.newBuilder());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new FlowSseClient with the specified HTTP client and request builder.
|
||||
*
|
||||
* @param httpClient the {@link HttpClient} instance to use for SSE connections
|
||||
* @param requestBuilder the {@link HttpRequest.Builder} to use for SSE requests
|
||||
*/
|
||||
public FlowSseClient(HttpClient httpClient, HttpRequest.Builder requestBuilder) {
|
||||
this.httpClient = httpClient;
|
||||
this.requestBuilder = requestBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to an SSE endpoint and processes the event stream.
|
||||
*
|
||||
* <p>
|
||||
* This method establishes a connection to the specified URL and begins processing the
|
||||
* SSE stream. Events are parsed and delivered to the provided event handler. The
|
||||
* connection remains active until either an error occurs or the server closes the
|
||||
* connection.
|
||||
*
|
||||
* @param url the SSE endpoint URL to connect to
|
||||
* @param eventHandler the handler that will receive SSE events and error
|
||||
* notifications
|
||||
* @throws RuntimeException if the connection fails with a non-200 status code
|
||||
*/
|
||||
public void subscribe(String url, SseEventHandler eventHandler) {
|
||||
this.url = url;
|
||||
HttpRequest request = this.requestBuilder.uri(URI.create(url))
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
StringBuilder eventBuilder = new StringBuilder();
|
||||
AtomicReference<String> currentEventId = new AtomicReference<>();
|
||||
AtomicReference<String> currentEventType = new AtomicReference<>("message");
|
||||
|
||||
Flow.Subscriber<String> lineSubscriber = new Flow.Subscriber<>() {
|
||||
|
||||
@Override
|
||||
public void onSubscribe(Flow.Subscription subscription) {
|
||||
FlowSseClient.this.subscription = subscription;
|
||||
subscription.request(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(String line) {
|
||||
if (line.isEmpty()) {
|
||||
// Empty line means end of event
|
||||
if (eventBuilder.length() > 0) {
|
||||
String eventData = eventBuilder.toString();
|
||||
SseEvent event = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
|
||||
eventHandler.onEvent(event);
|
||||
eventBuilder.setLength(0);
|
||||
}
|
||||
} else {
|
||||
if (line.startsWith("data:")) {
|
||||
var matcher = EVENT_DATA_PATTERN.matcher(line);
|
||||
if (matcher.find()) {
|
||||
eventBuilder.append(matcher.group(1).trim()).append("\n");
|
||||
}
|
||||
} else if (line.startsWith("id:")) {
|
||||
var matcher = EVENT_ID_PATTERN.matcher(line);
|
||||
if (matcher.find()) {
|
||||
currentEventId.set(matcher.group(1).trim());
|
||||
}
|
||||
} else if (line.startsWith("event:")) {
|
||||
var matcher = EVENT_TYPE_PATTERN.matcher(line);
|
||||
if (matcher.find()) {
|
||||
currentEventType.set(matcher.group(1).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
subscription.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable throwable) {
|
||||
eventHandler.onError(throwable);
|
||||
try {
|
||||
subscription.cancel();
|
||||
} catch (Exception e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
// Handle any remaining event data
|
||||
if (eventBuilder.length() > 0) {
|
||||
String eventData = eventBuilder.toString();
|
||||
SseEvent event = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
|
||||
eventHandler.onEvent(event);
|
||||
}
|
||||
try {
|
||||
subscription.cancel();
|
||||
} catch (Exception e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Function<Flow.Subscriber<String>, HttpResponse.BodySubscriber<Void>> subscriberFactory = subscriber -> HttpResponse.BodySubscribers
|
||||
.fromLineSubscriber(subscriber);
|
||||
future = this.httpClient.sendAsync(request, info -> subscriberFactory.apply(lineSubscriber));
|
||||
future.thenAccept(response -> {
|
||||
int status = response.statusCode();
|
||||
if (status != 200 && status != 201 && status != 202 && status != 206) {
|
||||
throw new RuntimeException("Failed to connect to SSE stream. Unexpected status code: " + status);
|
||||
}
|
||||
}).exceptionally(throwable -> {
|
||||
eventHandler.onError(throwable);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public void close() {
|
||||
log.debug("Closing FlowSseClient {}", this.url);
|
||||
if (this.subscription != null) {
|
||||
try {
|
||||
this.subscription.cancel();
|
||||
} catch (Exception e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
if (this.future != null) {
|
||||
try {
|
||||
this.future.cancel(true);
|
||||
} catch (Exception e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Server-Sent Events (SSE) implementation of the
|
||||
* {@link io.modelcontextprotocol.spec.McpTransport} that follows the MCP HTTP with SSE
|
||||
* transport specification, using Java's HttpClient.
|
||||
*
|
||||
* <p>
|
||||
* This transport implementation establishes a bidirectional communication channel between
|
||||
* client and server using SSE for server-to-client messages and HTTP POST requests for
|
||||
* client-to-server messages. The transport:
|
||||
* <ul>
|
||||
* <li>Establishes an SSE connection to receive server messages</li>
|
||||
* <li>Handles endpoint discovery through SSE events</li>
|
||||
* <li>Manages message serialization/deserialization using Jackson</li>
|
||||
* <li>Provides graceful connection termination</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The transport supports two types of SSE events:
|
||||
* <ul>
|
||||
* <li>'endpoint' - Contains the URL for sending client messages</li>
|
||||
* <li>'message' - Contains JSON-RPC message payload</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @see io.modelcontextprotocol.spec.McpTransport
|
||||
* @see io.modelcontextprotocol.spec.McpClientTransport
|
||||
*/
|
||||
public class HttpClientSseClientTransport implements McpClientTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HttpClientSseClientTransport.class);
|
||||
|
||||
/**
|
||||
* SSE event type for JSON-RPC messages
|
||||
*/
|
||||
private static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/**
|
||||
* SSE event type for endpoint discovery
|
||||
*/
|
||||
private static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path
|
||||
*/
|
||||
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
/**
|
||||
* Base URI for the MCP server
|
||||
*/
|
||||
private final String baseUri;
|
||||
|
||||
/**
|
||||
* SSE endpoint path
|
||||
*/
|
||||
private final String sseEndpoint;
|
||||
|
||||
/**
|
||||
* SSE client for handling server-sent events. Uses the /sse endpoint
|
||||
*/
|
||||
private final FlowSseClient sseClient;
|
||||
|
||||
/**
|
||||
* HTTP client for sending messages to the server. Uses HTTP POST over the message
|
||||
* endpoint
|
||||
*/
|
||||
private final HttpClient httpClient;
|
||||
|
||||
/**
|
||||
* HTTP request builder for building requests to send messages to the server
|
||||
*/
|
||||
private final HttpRequest.Builder requestBuilder;
|
||||
|
||||
/**
|
||||
* JSON object mapper for message serialization/deserialization
|
||||
*/
|
||||
protected ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Flag indicating if the transport is in closing state
|
||||
*/
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/**
|
||||
* Latch for coordinating endpoint discovery
|
||||
*/
|
||||
private final CountDownLatch closeLatch = new CountDownLatch(1);
|
||||
|
||||
/**
|
||||
* Holds the discovered message endpoint URL
|
||||
*/
|
||||
private final AtomicReference<String> messageEndpoint = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* Holds the SSE connection future
|
||||
*/
|
||||
private final AtomicReference<CompletableFuture<Void>> connectionFuture = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* Creates a new transport instance with default HTTP client and object mapper.
|
||||
*
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
|
||||
* constructor will be removed in future versions.
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public HttpClientSseClientTransport(String baseUri) {
|
||||
this(HttpClient.newBuilder(), baseUri, new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new transport instance with custom HTTP client builder and object mapper.
|
||||
*
|
||||
* @param clientBuilder the HTTP client builder to use
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @param objectMapper the object mapper for JSON serialization/deserialization
|
||||
* @throws IllegalArgumentException if objectMapper or clientBuilder is null
|
||||
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
|
||||
* constructor will be removed in future versions.
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, ObjectMapper objectMapper) {
|
||||
this(clientBuilder, baseUri, DEFAULT_SSE_ENDPOINT, objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new transport instance with custom HTTP client builder and object mapper.
|
||||
*
|
||||
* @param clientBuilder the HTTP client builder to use
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @param sseEndpoint the SSE endpoint path
|
||||
* @param objectMapper the object mapper for JSON serialization/deserialization
|
||||
* @throws IllegalArgumentException if objectMapper or clientBuilder is null
|
||||
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
|
||||
* constructor will be removed in future versions.
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, String baseUri, String sseEndpoint,
|
||||
ObjectMapper objectMapper) {
|
||||
this(clientBuilder, HttpRequest.newBuilder(), baseUri, sseEndpoint, objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new transport instance with custom HTTP client builder, object mapper,
|
||||
* and headers.
|
||||
*
|
||||
* @param clientBuilder the HTTP client builder to use
|
||||
* @param requestBuilder the HTTP request builder to use
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @param sseEndpoint the SSE endpoint path
|
||||
* @param objectMapper the object mapper for JSON serialization/deserialization
|
||||
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
|
||||
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead. This
|
||||
* constructor will be removed in future versions.
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, HttpRequest.Builder requestBuilder,
|
||||
String baseUri, String sseEndpoint, ObjectMapper objectMapper) {
|
||||
this(clientBuilder.connectTimeout(Duration.ofSeconds(10)).build(), requestBuilder, baseUri, sseEndpoint,
|
||||
objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new transport instance with custom HTTP client builder, object mapper,
|
||||
* and headers.
|
||||
*
|
||||
* @param httpClient the HTTP client to use
|
||||
* @param requestBuilder the HTTP request builder to use
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @param sseEndpoint the SSE endpoint path
|
||||
* @param objectMapper the object mapper for JSON serialization/deserialization
|
||||
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
|
||||
*/
|
||||
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
|
||||
String sseEndpoint, ObjectMapper objectMapper) {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||
Assert.hasText(baseUri, "baseUri must not be empty");
|
||||
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
|
||||
Assert.notNull(httpClient, "httpClient must not be null");
|
||||
Assert.notNull(requestBuilder, "requestBuilder must not be null");
|
||||
this.baseUri = baseUri;
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = httpClient;
|
||||
this.requestBuilder = requestBuilder;
|
||||
|
||||
this.sseClient = new FlowSseClient(this.httpClient, requestBuilder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder for {@link HttpClientSseClientTransport}.
|
||||
*
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @return a new builder instance
|
||||
*/
|
||||
public static Builder builder(String baseUri) {
|
||||
return new Builder().baseUri(baseUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link HttpClientSseClientTransport}.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private String baseUri;
|
||||
|
||||
private String sseEndpoint = DEFAULT_SSE_ENDPOINT;
|
||||
|
||||
private HttpClient.Builder clientBuilder = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10));
|
||||
|
||||
private HttpClient httpClient;
|
||||
|
||||
private ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
/**
|
||||
* Creates a new builder instance.
|
||||
*/
|
||||
Builder() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder with the specified base URI.
|
||||
*
|
||||
* @param baseUri the base URI of the MCP server
|
||||
* @deprecated Use {@link HttpClientSseClientTransport#builder(String)} instead.
|
||||
* This constructor is deprecated and will be removed or made {@code protected} or
|
||||
* {@code private} in a future release.
|
||||
*/
|
||||
@Deprecated(forRemoval = true)
|
||||
public Builder(String baseUri) {
|
||||
Assert.hasText(baseUri, "baseUri must not be empty");
|
||||
this.baseUri = baseUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the base URI.
|
||||
*
|
||||
* @param baseUri the base URI
|
||||
* @return this builder
|
||||
*/
|
||||
Builder baseUri(String baseUri) {
|
||||
Assert.hasText(baseUri, "baseUri must not be empty");
|
||||
this.baseUri = baseUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the SSE endpoint path.
|
||||
*
|
||||
* @param sseEndpoint the SSE endpoint path
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder sseEndpoint(String sseEndpoint) {
|
||||
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the HTTP client builder.
|
||||
*
|
||||
* @param clientBuilder the HTTP client builder
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder clientBuilder(HttpClient.Builder clientBuilder) {
|
||||
Assert.notNull(clientBuilder, "clientBuilder must not be null");
|
||||
this.clientBuilder = clientBuilder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder httpClient(HttpClient httpClient) {
|
||||
Assert.notNull(httpClient, "httpClient must not be null");
|
||||
this.httpClient = httpClient;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes the HTTP client builder.
|
||||
*
|
||||
* @param clientCustomizer the consumer to customize the HTTP client builder
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder customizeClient(final Consumer<HttpClient.Builder> clientCustomizer) {
|
||||
Assert.notNull(clientCustomizer, "clientCustomizer must not be null");
|
||||
clientCustomizer.accept(clientBuilder);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the HTTP request builder.
|
||||
*
|
||||
* @param requestBuilder the HTTP request builder
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder requestBuilder(HttpRequest.Builder requestBuilder) {
|
||||
Assert.notNull(requestBuilder, "requestBuilder must not be null");
|
||||
this.requestBuilder = requestBuilder;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes the HTTP client builder.
|
||||
*
|
||||
* @param requestCustomizer the consumer to customize the HTTP request builder
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder customizeRequest(final Consumer<HttpRequest.Builder> requestCustomizer) {
|
||||
Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
|
||||
requestCustomizer.accept(requestBuilder);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the object mapper for JSON serialization/deserialization.
|
||||
*
|
||||
* @param objectMapper the object mapper
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder objectMapper(ObjectMapper objectMapper) {
|
||||
Assert.notNull(objectMapper, "objectMapper must not be null");
|
||||
this.objectMapper = objectMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new {@link HttpClientSseClientTransport} instance.
|
||||
*
|
||||
* @return a new transport instance
|
||||
*/
|
||||
public HttpClientSseClientTransport build() {
|
||||
return new HttpClientSseClientTransport(httpClient != null ? httpClient : clientBuilder.build(), requestBuilder, baseUri, sseEndpoint,
|
||||
objectMapper);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the SSE connection with the server and sets up message handling.
|
||||
*
|
||||
* <p>
|
||||
* This method:
|
||||
* <ul>
|
||||
* <li>Initiates the SSE connection</li>
|
||||
* <li>Handles endpoint discovery events</li>
|
||||
* <li>Processes incoming JSON-RPC messages</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param handler the function to process received JSON-RPC messages
|
||||
* @return a Mono that completes when the connection is established
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
connectionFuture.set(future);
|
||||
|
||||
sseClient.subscribe(this.baseUri + this.sseEndpoint, new FlowSseClient.SseEventHandler() {
|
||||
@Override
|
||||
public void onEvent(FlowSseClient.SseEvent event) {
|
||||
if (isClosing) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (ENDPOINT_EVENT_TYPE.equals(event.type())) {
|
||||
String endpoint = event.data();
|
||||
messageEndpoint.set(endpoint);
|
||||
closeLatch.countDown();
|
||||
future.complete(null);
|
||||
} else if (MESSAGE_EVENT_TYPE.equals(event.type())) {
|
||||
JSONRPCMessage message;
|
||||
try {
|
||||
message = McpSchema.deserializeJsonRpcMessage(objectMapper, event.data());
|
||||
} catch (Exception e) {
|
||||
logger.error("Error deserializing JSON-RPC message", e);
|
||||
handler.apply(Mono.error(e)).subscribe();
|
||||
return;
|
||||
}
|
||||
handler.apply(Mono.just(message)).subscribe();
|
||||
} else {
|
||||
logger.error("Received unrecognized SSE event type: {}", event.type());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Error processing SSE event", e);
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
if (!isClosing) {
|
||||
logger.error("SSE connection error", error);
|
||||
future.completeExceptionally(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Mono.fromFuture(future);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC message to the server.
|
||||
*
|
||||
* <p>
|
||||
* This method waits for the message endpoint to be discovered before sending the
|
||||
* message. The message is serialized to JSON and sent as an HTTP POST request.
|
||||
*
|
||||
* @param message the JSON-RPC message to send
|
||||
* @return a Mono that completes when the message is sent
|
||||
* @throws McpError if the message endpoint is not available or the wait times out
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> sendMessage(JSONRPCMessage message) {
|
||||
if (isClosing) {
|
||||
logger.debug("Transport is closing");
|
||||
return Mono.error(new McpError("isClosing"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!closeLatch.await(10, TimeUnit.SECONDS)) {
|
||||
return Mono.error(new McpError("Failed to wait for the message endpoint"));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
return Mono.error(new McpError("Failed to wait for the message endpoint"));
|
||||
}
|
||||
|
||||
String endpoint = messageEndpoint.get();
|
||||
if (endpoint == null) {
|
||||
return Mono.error(new McpError("No message endpoint available"));
|
||||
}
|
||||
|
||||
try {
|
||||
String jsonText = this.objectMapper.writeValueAsString(message);
|
||||
HttpRequest.Builder builder = requestBuilder.copy().setHeader("accept", "*/*");
|
||||
HttpRequest request = builder.uri(URI.create(this.baseUri + endpoint))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonText))
|
||||
.build();
|
||||
|
||||
return Mono.fromFuture(
|
||||
httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()).thenAccept(response -> {
|
||||
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 202
|
||||
&& response.statusCode() != 206) {
|
||||
logger.error("Error sending message: {}", response.statusCode());
|
||||
}
|
||||
}));
|
||||
} catch (IOException e) {
|
||||
if (!isClosing) {
|
||||
return Mono.error(new RuntimeException("Failed to serialize message", e));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the transport connection.
|
||||
*
|
||||
* <p>
|
||||
* Sets the closing flag and cancels any pending connection future. This prevents new
|
||||
* messages from being sent and allows ongoing operations to complete.
|
||||
*
|
||||
* @return a Mono that completes when the closing process is initiated
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> {
|
||||
isClosing = true;
|
||||
CompletableFuture<Void> future = connectionFuture.get();
|
||||
if (future != null && !future.isDone()) {
|
||||
future.cancel(true);
|
||||
}
|
||||
|
||||
if (sseClient != null) {
|
||||
sseClient.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmarshal data to the specified type using the configured object mapper.
|
||||
*
|
||||
* @param data the data to unmarshal
|
||||
* @param typeRef the type reference for the target type
|
||||
* @param <T> the target type
|
||||
* @return the unmarshalled object
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return this.objectMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.xspaceagi.system.spec.utils.TimeWheel;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.*;
|
||||
import io.modelcontextprotocol.spec.McpTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* The Model Context Protocol (MCP) client implementation that provides asynchronous
|
||||
* communication with MCP servers using Project Reactor's Mono and Flux types.
|
||||
*
|
||||
* <p>
|
||||
* This client implements the MCP specification, enabling AI models to interact with
|
||||
* external tools and resources through a standardized interface. Key features include:
|
||||
* <ul>
|
||||
* <li>Asynchronous communication using reactive programming patterns
|
||||
* <li>Tool discovery and invocation for server-provided functionality
|
||||
* <li>Resource access and management with URI-based addressing
|
||||
* <li>Prompt template handling for standardized AI interactions
|
||||
* <li>Real-time notifications for tools, resources, and prompts changes
|
||||
* <li>Structured logging with configurable severity levels
|
||||
* <li>Message sampling for AI model interactions
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The client follows a lifecycle:
|
||||
* <ol>
|
||||
* <li>Initialization - Establishes connection and negotiates capabilities
|
||||
* <li>Normal Operation - Handles requests and notifications
|
||||
* <li>Graceful Shutdown - Ensures clean connection termination
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* This implementation uses Project Reactor for non-blocking operations, making it
|
||||
* suitable for high-throughput scenarios and reactive applications. All operations return
|
||||
* Mono or Flux types that can be composed into reactive pipelines.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @author Christian Tzolov
|
||||
* @see McpClient
|
||||
* @see McpSchema
|
||||
* @see McpClientSession
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpAsyncClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpAsyncClient.class);
|
||||
|
||||
private static TypeReference<Void> VOID_TYPE_REFERENCE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
protected final Sinks.One<McpSchema.InitializeResult> initializedSink = Sinks.one();
|
||||
|
||||
private AtomicBoolean initialized = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* The max timeout to await for the client-server connection to be initialized.
|
||||
*/
|
||||
private final Duration initializationTimeout;
|
||||
|
||||
/**
|
||||
* The MCP session implementation that manages bidirectional JSON-RPC communication
|
||||
* between clients and servers.
|
||||
*/
|
||||
private final McpClientSession mcpSession;
|
||||
|
||||
/**
|
||||
* Client capabilities.
|
||||
*/
|
||||
private final McpSchema.ClientCapabilities clientCapabilities;
|
||||
|
||||
/**
|
||||
* Client implementation information.
|
||||
*/
|
||||
private final McpSchema.Implementation clientInfo;
|
||||
|
||||
/**
|
||||
* Server capabilities.
|
||||
*/
|
||||
private McpSchema.ServerCapabilities serverCapabilities;
|
||||
|
||||
/**
|
||||
* Server implementation information.
|
||||
*/
|
||||
private McpSchema.Implementation serverInfo;
|
||||
|
||||
/**
|
||||
* Roots define the boundaries of where servers can operate within the filesystem,
|
||||
* allowing them to understand which directories and files they have access to.
|
||||
* Servers can request the list of roots from supporting clients and receive
|
||||
* notifications when that list changes.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Root> roots;
|
||||
|
||||
/**
|
||||
* MCP provides a standardized way for servers to request LLM sampling ("completions"
|
||||
* or "generations") from language models via clients. This flow allows clients to
|
||||
* maintain control over model access, selection, and permissions while enabling
|
||||
* servers to leverage AI capabilities—with no server API keys necessary. Servers can
|
||||
* request text or image-based interactions and optionally include context from MCP
|
||||
* servers in their prompts.
|
||||
*/
|
||||
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
|
||||
|
||||
/**
|
||||
* Client transport implementation.
|
||||
*/
|
||||
private final McpTransport transport;
|
||||
|
||||
/**
|
||||
* Supported protocol versions.
|
||||
*/
|
||||
private List<String> protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
|
||||
|
||||
private volatile boolean isClosed = false;
|
||||
|
||||
/**
|
||||
* Create a new McpAsyncClient with the given transport and session request-response
|
||||
* timeout.
|
||||
*
|
||||
* @param transport the transport to use.
|
||||
* @param requestTimeout the session request-response timeout.
|
||||
* @param initializationTimeout the max timeout to await for the client-server
|
||||
* @param features the MCP Client supported features.
|
||||
*/
|
||||
McpAsyncClient(McpClientTransport transport, Duration requestTimeout, Duration initializationTimeout,
|
||||
McpClientFeatures.Async features) {
|
||||
|
||||
Assert.notNull(transport, "Transport must not be null");
|
||||
Assert.notNull(requestTimeout, "Request timeout must not be null");
|
||||
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
|
||||
|
||||
this.clientInfo = features.clientInfo();
|
||||
this.clientCapabilities = features.clientCapabilities();
|
||||
this.transport = transport;
|
||||
this.roots = new ConcurrentHashMap<>(features.roots());
|
||||
this.initializationTimeout = initializationTimeout;
|
||||
|
||||
// Request Handlers
|
||||
Map<String, McpClientSession.RequestHandler<?>> requestHandlers = new HashMap<>();
|
||||
|
||||
// Roots List Request Handler
|
||||
if (this.clientCapabilities.roots() != null) {
|
||||
requestHandlers.put(McpSchema.METHOD_ROOTS_LIST, rootsListRequestHandler());
|
||||
}
|
||||
|
||||
// Sampling Handler
|
||||
if (this.clientCapabilities.sampling() != null) {
|
||||
if (features.samplingHandler() == null) {
|
||||
throw new McpError("Sampling handler must not be null when client capabilities include sampling");
|
||||
}
|
||||
this.samplingHandler = features.samplingHandler();
|
||||
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
|
||||
}
|
||||
|
||||
// Notification Handlers
|
||||
Map<String, McpClientSession.NotificationHandler> notificationHandlers = new HashMap<>();
|
||||
|
||||
// Tools Change Notification
|
||||
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumersFinal = new ArrayList<>();
|
||||
toolsChangeConsumersFinal
|
||||
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Tools changed: {}", notification)));
|
||||
|
||||
if (!Utils.isEmpty(features.toolsChangeConsumers())) {
|
||||
toolsChangeConsumersFinal.addAll(features.toolsChangeConsumers());
|
||||
}
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
|
||||
asyncToolsChangeNotificationHandler(toolsChangeConsumersFinal));
|
||||
|
||||
// Resources Change Notification
|
||||
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumersFinal = new ArrayList<>();
|
||||
resourcesChangeConsumersFinal
|
||||
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Resources changed: {}", notification)));
|
||||
|
||||
if (!Utils.isEmpty(features.resourcesChangeConsumers())) {
|
||||
resourcesChangeConsumersFinal.addAll(features.resourcesChangeConsumers());
|
||||
}
|
||||
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED,
|
||||
asyncResourcesChangeNotificationHandler(resourcesChangeConsumersFinal));
|
||||
|
||||
// Prompts Change Notification
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumersFinal = new ArrayList<>();
|
||||
promptsChangeConsumersFinal
|
||||
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Prompts changed: {}", notification)));
|
||||
if (!Utils.isEmpty(features.promptsChangeConsumers())) {
|
||||
promptsChangeConsumersFinal.addAll(features.promptsChangeConsumers());
|
||||
}
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED,
|
||||
asyncPromptsChangeNotificationHandler(promptsChangeConsumersFinal));
|
||||
|
||||
// Utility Logging Notification
|
||||
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumersFinal = new ArrayList<>();
|
||||
loggingConsumersFinal.add((notification) -> Mono.fromRunnable(() -> logger.debug("Logging: {}", notification)));
|
||||
if (!Utils.isEmpty(features.loggingConsumers())) {
|
||||
loggingConsumersFinal.addAll(features.loggingConsumers());
|
||||
}
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_MESSAGE,
|
||||
asyncLoggingNotificationHandler(loggingConsumersFinal));
|
||||
|
||||
this.mcpSession = new McpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers);
|
||||
}
|
||||
|
||||
private void nextHeartbeat() {
|
||||
if (isClosed) {
|
||||
log.debug("MCP heartbeat ended, {}", serverInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
TimeWheel.getInstance().schedule((res) -> {
|
||||
if (!isClosed) {
|
||||
log.debug("Sending MCP heartbeat, {}", serverInfo);
|
||||
ping().timeout(Duration.ofSeconds(20))
|
||||
.onErrorResume(throwable -> {
|
||||
if (throwable instanceof TimeoutException) {
|
||||
log.error("MCP heartbeat timeout", throwable);
|
||||
return Mono.error(throwable);
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
.doOnError(throwable -> {
|
||||
log.error("MCP heartbeat failed {}", serverInfo, throwable);
|
||||
close();
|
||||
})
|
||||
.doOnSuccess(ping -> nextHeartbeat()).subscribe();
|
||||
} else {
|
||||
log.debug("Session closed, MCP heartbeat ended, {}", serverInfo);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server capabilities that define the supported features and functionality.
|
||||
*
|
||||
* @return The server capabilities
|
||||
*/
|
||||
public McpSchema.ServerCapabilities getServerCapabilities() {
|
||||
return this.serverCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server implementation information.
|
||||
*
|
||||
* @return The server implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getServerInfo() {
|
||||
return this.serverInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client-server connection is initialized.
|
||||
*
|
||||
* @return true if the client-server connection is initialized
|
||||
*/
|
||||
public boolean isInitialized() {
|
||||
return this.initialized.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client capabilities that define the supported features and functionality.
|
||||
*
|
||||
* @return The client capabilities
|
||||
*/
|
||||
public ClientCapabilities getClientCapabilities() {
|
||||
return this.clientCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client implementation information.
|
||||
*
|
||||
* @return The client implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getClientInfo() {
|
||||
return this.clientInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the client connection immediately.
|
||||
*/
|
||||
public void close() {
|
||||
this.mcpSession.close();
|
||||
isClosed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the client connection.
|
||||
*
|
||||
* @return A Mono that completes when the connection is closed
|
||||
*/
|
||||
public Mono<Void> closeGracefully() {
|
||||
return this.mcpSession.closeGracefully();
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Initialization
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* The initialization phase MUST be the first interaction between client and server.
|
||||
* During this phase, the client and server:
|
||||
* <ul>
|
||||
* <li>Establish protocol version compatibility</li>
|
||||
* <li>Exchange and negotiate capabilities</li>
|
||||
* <li>Share implementation details</li>
|
||||
* </ul>
|
||||
* <br/>
|
||||
* The client MUST initiate this phase by sending an initialize request containing:
|
||||
* The protocol version the client supports, client's capabilities and clients
|
||||
* implementation information.
|
||||
* <p/>
|
||||
* The server MUST respond with its own capabilities and information.
|
||||
* <p/>
|
||||
* After successful initialization, the client MUST send an initialized notification
|
||||
* to indicate it is ready to begin normal operations.
|
||||
*
|
||||
* @return the initialize result.
|
||||
* @see <a href=
|
||||
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">MCP
|
||||
* Initialization Spec</a>
|
||||
*/
|
||||
public Mono<McpSchema.InitializeResult> initialize() {
|
||||
|
||||
String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
|
||||
|
||||
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(// @formatter:off
|
||||
latestVersion,
|
||||
this.clientCapabilities,
|
||||
this.clientInfo); // @formatter:on
|
||||
|
||||
Mono<McpSchema.InitializeResult> result = this.mcpSession.sendRequest(McpSchema.METHOD_INITIALIZE,
|
||||
initializeRequest, new TypeReference<McpSchema.InitializeResult>() {
|
||||
});
|
||||
|
||||
return result.flatMap(initializeResult -> {
|
||||
|
||||
this.serverCapabilities = initializeResult.capabilities();
|
||||
this.serverInfo = initializeResult.serverInfo();
|
||||
|
||||
logger.info("Server response with Protocol: {}, Capabilities: {}, Info: {} and Instructions {}",
|
||||
initializeResult.protocolVersion(), initializeResult.capabilities(), initializeResult.serverInfo(),
|
||||
initializeResult.instructions());
|
||||
|
||||
if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
|
||||
return Mono.error(new McpError(
|
||||
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
||||
}
|
||||
|
||||
nextHeartbeat();
|
||||
|
||||
return this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null).doOnSuccess(v -> {
|
||||
this.initialized.set(true);
|
||||
this.initializedSink.tryEmitValue(initializeResult);
|
||||
}).thenReturn(initializeResult);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to handle the common pattern of checking initialization before
|
||||
* executing an operation.
|
||||
*
|
||||
* @param <T> The type of the result Mono
|
||||
* @param actionName The action to perform if the client is initialized
|
||||
* @param operation The operation to execute if the client is initialized
|
||||
* @return A Mono that completes with the result of the operation
|
||||
*/
|
||||
private <T> Mono<T> withInitializationCheck(String actionName,
|
||||
Function<McpSchema.InitializeResult, Mono<T>> operation) {
|
||||
return this.initializedSink.asMono()
|
||||
.timeout(this.initializationTimeout)
|
||||
.onErrorResume(TimeoutException.class,
|
||||
ex -> Mono.error(new McpError("Client must be initialized before " + actionName)))
|
||||
.flatMap(operation);
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Basic Utilities
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* Sends a ping request to the server.
|
||||
*
|
||||
* @return A Mono that completes with the server's ping response
|
||||
*/
|
||||
public Mono<Object> ping() {
|
||||
return this.withInitializationCheck("pinging the server", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_PING, null, new TypeReference<Object>() {
|
||||
}));
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Roots
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* Adds a new root to the client's root list.
|
||||
*
|
||||
* @param root The root to add.
|
||||
* @return A Mono that completes when the root is added and notifications are sent.
|
||||
*/
|
||||
public Mono<Void> addRoot(Root root) {
|
||||
|
||||
if (root == null) {
|
||||
return Mono.error(new McpError("Root must not be null"));
|
||||
}
|
||||
|
||||
if (this.clientCapabilities.roots() == null) {
|
||||
return Mono.error(new McpError("Client must be configured with roots capabilities"));
|
||||
}
|
||||
|
||||
if (this.roots.containsKey(root.uri())) {
|
||||
return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
|
||||
}
|
||||
|
||||
this.roots.put(root.uri(), root);
|
||||
|
||||
logger.debug("Added root: {}", root);
|
||||
|
||||
if (this.clientCapabilities.roots().listChanged()) {
|
||||
if (this.isInitialized()) {
|
||||
return this.rootsListChangedNotification();
|
||||
} else {
|
||||
logger.warn("Client is not initialized, ignore sending a roots list changed notification");
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a root from the client's root list.
|
||||
*
|
||||
* @param rootUri The URI of the root to remove.
|
||||
* @return A Mono that completes when the root is removed and notifications are sent.
|
||||
*/
|
||||
public Mono<Void> removeRoot(String rootUri) {
|
||||
|
||||
if (rootUri == null) {
|
||||
return Mono.error(new McpError("Root uri must not be null"));
|
||||
}
|
||||
|
||||
if (this.clientCapabilities.roots() == null) {
|
||||
return Mono.error(new McpError("Client must be configured with roots capabilities"));
|
||||
}
|
||||
|
||||
Root removed = this.roots.remove(rootUri);
|
||||
|
||||
if (removed != null) {
|
||||
logger.debug("Removed Root: {}", rootUri);
|
||||
if (this.clientCapabilities.roots().listChanged()) {
|
||||
if (this.isInitialized()) {
|
||||
return this.rootsListChangedNotification();
|
||||
} else {
|
||||
logger.warn("Client is not initialized, ignore sending a roots list changed notification");
|
||||
}
|
||||
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually sends a roots/list_changed notification. The addRoot and removeRoot
|
||||
* methods automatically send the roots/list_changed notification if the client is in
|
||||
* an initialized state.
|
||||
*
|
||||
* @return A Mono that completes when the notification is sent.
|
||||
*/
|
||||
public Mono<Void> rootsListChangedNotification() {
|
||||
return this.withInitializationCheck("sending roots list changed notification",
|
||||
initResult -> this.mcpSession.sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED));
|
||||
}
|
||||
|
||||
private McpClientSession.RequestHandler<McpSchema.ListRootsResult> rootsListRequestHandler() {
|
||||
return params -> {
|
||||
@SuppressWarnings("unused")
|
||||
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params,
|
||||
new TypeReference<McpSchema.PaginatedRequest>() {
|
||||
});
|
||||
|
||||
List<Root> roots = this.roots.values().stream().toList();
|
||||
|
||||
return Mono.just(new McpSchema.ListRootsResult(roots));
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Sampling
|
||||
// --------------------------
|
||||
private McpClientSession.RequestHandler<CreateMessageResult> samplingCreateMessageHandler() {
|
||||
return params -> {
|
||||
McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params,
|
||||
new TypeReference<McpSchema.CreateMessageRequest>() {
|
||||
});
|
||||
|
||||
return this.samplingHandler.apply(request);
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Tools
|
||||
// --------------------------
|
||||
private static final TypeReference<McpSchema.CallToolResult> CALL_TOOL_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private static final TypeReference<McpSchema.ListToolsResult> LIST_TOOLS_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls a tool provided by the server. Tools enable servers to expose executable
|
||||
* functionality that can interact with external systems, perform computations, and
|
||||
* take actions in the real world.
|
||||
*
|
||||
* @param callToolRequest The request containing the tool name and input parameters.
|
||||
* @return A Mono that emits the result of the tool call, including the output and any
|
||||
* errors.
|
||||
* @see McpSchema.CallToolRequest
|
||||
* @see McpSchema.CallToolResult
|
||||
* @see #listTools()
|
||||
*/
|
||||
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
|
||||
return this.withInitializationCheck("calling tools", initializedResult -> {
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all tools provided by the server.
|
||||
*
|
||||
* @return A Mono that emits the list of tools result.
|
||||
*/
|
||||
public Mono<McpSchema.ListToolsResult> listTools() {
|
||||
return this.listTools(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of tools provided by the server.
|
||||
*
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return A Mono that emits the list of tools result
|
||||
*/
|
||||
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
|
||||
return this.withInitializationCheck("listing tools", initializedResult -> {
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_TOOLS_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
private McpClientSession.NotificationHandler asyncToolsChangeNotificationHandler(
|
||||
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers) {
|
||||
// TODO: params are not used yet
|
||||
return params -> this.listTools()
|
||||
.flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
|
||||
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling tools list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Resources
|
||||
// --------------------------
|
||||
|
||||
private static final TypeReference<McpSchema.ListResourcesResult> LIST_RESOURCES_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private static final TypeReference<McpSchema.ReadResourceResult> READ_RESOURCE_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private static final TypeReference<McpSchema.ListResourceTemplatesResult> LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the list of all resources provided by the server. Resources represent any
|
||||
* kind of UTF-8 encoded data that an MCP server makes available to clients, such as
|
||||
* database records, API responses, log files, and more.
|
||||
*
|
||||
* @return A Mono that completes with the list of resources result.
|
||||
* @see McpSchema.ListResourcesResult
|
||||
* @see #readResource(McpSchema.Resource)
|
||||
*/
|
||||
public Mono<McpSchema.ListResourcesResult> listResources() {
|
||||
return this.listResources(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of resources provided by the server. Resources represent
|
||||
* any kind of UTF-8 encoded data that an MCP server makes available to clients, such
|
||||
* as database records, API responses, log files, and more.
|
||||
*
|
||||
* @param cursor Optional pagination cursor from a previous list request.
|
||||
* @return A Mono that completes with the list of resources result.
|
||||
* @see McpSchema.ListResourcesResult
|
||||
* @see #readResource(McpSchema.Resource)
|
||||
*/
|
||||
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
|
||||
return this.withInitializationCheck("listing resources", initializedResult -> {
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_RESOURCES_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the content of a specific resource identified by the provided Resource
|
||||
* object. This method fetches the actual data that the resource represents.
|
||||
*
|
||||
* @param resource The resource to read, containing the URI that identifies the
|
||||
* resource.
|
||||
* @return A Mono that completes with the resource content.
|
||||
* @see McpSchema.Resource
|
||||
* @see McpSchema.ReadResourceResult
|
||||
*/
|
||||
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.Resource resource) {
|
||||
return this.readResource(new McpSchema.ReadResourceRequest(resource.uri()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the content of a specific resource identified by the provided request. This
|
||||
* method fetches the actual data that the resource represents.
|
||||
*
|
||||
* @param readResourceRequest The request containing the URI of the resource to read
|
||||
* @return A Mono that completes with the resource content.
|
||||
* @see McpSchema.ReadResourceRequest
|
||||
* @see McpSchema.ReadResourceResult
|
||||
*/
|
||||
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
|
||||
return this.withInitializationCheck("reading resources", initializedResult -> {
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest,
|
||||
READ_RESOURCE_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all resource templates provided by the server. Resource
|
||||
* templates allow servers to expose parameterized resources using URI templates,
|
||||
* enabling dynamic resource access based on variable parameters.
|
||||
*
|
||||
* @return A Mono that completes with the list of resource templates result.
|
||||
* @see McpSchema.ListResourceTemplatesResult
|
||||
*/
|
||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
|
||||
return this.listResourceTemplates(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of resource templates provided by the server. Resource
|
||||
* templates allow servers to expose parameterized resources using URI templates,
|
||||
* enabling dynamic resource access based on variable parameters.
|
||||
*
|
||||
* @param cursor Optional pagination cursor from a previous list request.
|
||||
* @return A Mono that completes with the list of resource templates result.
|
||||
* @see McpSchema.ListResourceTemplatesResult
|
||||
*/
|
||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
|
||||
return this.withInitializationCheck("listing resource templates", initializedResult -> {
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
}
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST,
|
||||
new McpSchema.PaginatedRequest(cursor), LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to changes in a specific resource. When the resource changes on the
|
||||
* server, the client will receive notifications through the resources change
|
||||
* notification handler.
|
||||
*
|
||||
* @param subscribeRequest The subscribe request containing the URI of the resource.
|
||||
* @return A Mono that completes when the subscription is complete.
|
||||
* @see McpSchema.SubscribeRequest
|
||||
* @see #unsubscribeResource(McpSchema.UnsubscribeRequest)
|
||||
*/
|
||||
public Mono<Void> subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
|
||||
return this.withInitializationCheck("subscribing to resources", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_SUBSCRIBE, subscribeRequest, VOID_TYPE_REFERENCE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an existing subscription to a resource. After unsubscribing, the client
|
||||
* will no longer receive notifications when the resource changes.
|
||||
*
|
||||
* @param unsubscribeRequest The unsubscribe request containing the URI of the
|
||||
* resource.
|
||||
* @return A Mono that completes when the unsubscription is complete.
|
||||
* @see McpSchema.UnsubscribeRequest
|
||||
* @see #subscribeResource(McpSchema.SubscribeRequest)
|
||||
*/
|
||||
public Mono<Void> unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
|
||||
return this.withInitializationCheck("unsubscribing from resources", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, VOID_TYPE_REFERENCE));
|
||||
}
|
||||
|
||||
private McpClientSession.NotificationHandler asyncResourcesChangeNotificationHandler(
|
||||
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers) {
|
||||
return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers)
|
||||
.flatMap(consumer -> consumer.apply(listResourcesResult.resources()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling resources list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Prompts
|
||||
// --------------------------
|
||||
private static final TypeReference<McpSchema.ListPromptsResult> LIST_PROMPTS_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private static final TypeReference<McpSchema.GetPromptResult> GET_PROMPT_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the list of all prompts provided by the server.
|
||||
*
|
||||
* @return A Mono that completes with the list of prompts result.
|
||||
* @see McpSchema.ListPromptsResult
|
||||
* @see #getPrompt(GetPromptRequest)
|
||||
*/
|
||||
public Mono<ListPromptsResult> listPrompts() {
|
||||
return this.listPrompts(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of prompts provided by the server.
|
||||
*
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return A Mono that completes with the list of prompts result.
|
||||
* @see McpSchema.ListPromptsResult
|
||||
* @see #getPrompt(GetPromptRequest)
|
||||
*/
|
||||
public Mono<ListPromptsResult> listPrompts(String cursor) {
|
||||
return this.withInitializationCheck("listing prompts", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a specific prompt by its ID. This provides the complete prompt template
|
||||
* including all parameters and instructions for generating AI content.
|
||||
*
|
||||
* @param getPromptRequest The request containing the ID of the prompt to retrieve.
|
||||
* @return A Mono that completes with the prompt result.
|
||||
* @see McpSchema.GetPromptRequest
|
||||
* @see McpSchema.GetPromptResult
|
||||
* @see #listPrompts()
|
||||
*/
|
||||
public Mono<GetPromptResult> getPrompt(GetPromptRequest getPromptRequest) {
|
||||
return this.withInitializationCheck("getting prompts", initializedResult -> this.mcpSession
|
||||
.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF));
|
||||
}
|
||||
|
||||
private McpClientSession.NotificationHandler asyncPromptsChangeNotificationHandler(
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers) {
|
||||
return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)
|
||||
.flatMap(consumer -> consumer.apply(listPromptsResult.prompts()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling prompts list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Logging
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* Create a notification handler for logging notifications from the server. This
|
||||
* handler automatically distributes logging messages to all registered consumers.
|
||||
*
|
||||
* @param loggingConsumers List of consumers that will be notified when a logging
|
||||
* message is received. Each consumer receives the logging message notification.
|
||||
* @return A NotificationHandler that processes log notifications by distributing the
|
||||
* message to all registered consumers
|
||||
*/
|
||||
private McpClientSession.NotificationHandler asyncLoggingNotificationHandler(
|
||||
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
|
||||
|
||||
return params -> {
|
||||
McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params,
|
||||
new TypeReference<McpSchema.LoggingMessageNotification>() {
|
||||
});
|
||||
|
||||
return Flux.fromIterable(loggingConsumers)
|
||||
.flatMap(consumer -> consumer.apply(loggingMessageNotification))
|
||||
.then();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimum logging level for messages received from the server. The client
|
||||
* will only receive log messages at or above the specified severity level.
|
||||
*
|
||||
* @param loggingLevel The minimum logging level to receive.
|
||||
* @return A Mono that completes when the logging level is set.
|
||||
* @see McpSchema.LoggingLevel
|
||||
*/
|
||||
public Mono<Void> setLoggingLevel(LoggingLevel loggingLevel) {
|
||||
if (loggingLevel == null) {
|
||||
return Mono.error(new McpError("Logging level must not be null"));
|
||||
}
|
||||
|
||||
return this.withInitializationCheck("setting logging level", initializedResult -> {
|
||||
var params = new McpSchema.SetLevelRequest(loggingLevel);
|
||||
return this.mcpSession.sendRequest(McpSchema.METHOD_LOGGING_SET_LEVEL, params, new TypeReference<Object>() {
|
||||
}).then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is package-private and used for test only. Should not be called by user
|
||||
* code.
|
||||
*
|
||||
* @param protocolVersions the Client supported protocol versions.
|
||||
*/
|
||||
void setProtocolVersions(List<String> protocolVersions) {
|
||||
this.protocolVersions = protocolVersions;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class McpAsyncClientWrapper {
|
||||
|
||||
private HttpClientSseClientTransport httpClientSseClientTransport;
|
||||
|
||||
private McpAsyncClient client;
|
||||
|
||||
public void close() {
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
if (httpClientSseClientTransport != null) {
|
||||
httpClientSseClientTransport.close();
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.create(sink -> {
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
if (httpClientSseClientTransport != null) {
|
||||
httpClientSseClientTransport.close();
|
||||
}
|
||||
sink.success();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.*;
|
||||
import io.modelcontextprotocol.spec.McpTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that
|
||||
* enables AI models to interact with external tools and resources through a standardized
|
||||
* interface.
|
||||
*
|
||||
* <p>
|
||||
* This class serves as the main entry point for establishing connections with MCP
|
||||
* servers, implementing the client-side of the MCP specification. The protocol follows a
|
||||
* client-server architecture where:
|
||||
* <ul>
|
||||
* <li>The client (this implementation) initiates connections and sends requests
|
||||
* <li>The server responds to requests and provides access to tools and resources
|
||||
* <li>Communication occurs through a transport layer (e.g., stdio, SSE) using JSON-RPC
|
||||
* 2.0
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The class provides factory methods to create either:
|
||||
* <ul>
|
||||
* <li>{@link McpAsyncClient} for non-blocking operations with CompletableFuture responses
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example of creating a basic synchronous client: <pre>{@code
|
||||
* McpClient.sync(transport)
|
||||
* .requestTimeout(Duration.ofSeconds(5))
|
||||
* .build();
|
||||
* }</pre>
|
||||
*
|
||||
* Example of creating a basic asynchronous client: <pre>{@code
|
||||
* McpClient.async(transport)
|
||||
* .requestTimeout(Duration.ofSeconds(5))
|
||||
* .build();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>
|
||||
* Example with advanced asynchronous configuration: <pre>{@code
|
||||
* McpClient.async(transport)
|
||||
* .requestTimeout(Duration.ofSeconds(10))
|
||||
* .capabilities(new ClientCapabilities(...))
|
||||
* .clientInfo(new Implementation("My Client", "1.0.0"))
|
||||
* .roots(new Root("file://workspace", "Workspace Files"))
|
||||
* .toolsChangeConsumer(tools -> Mono.fromRunnable(() -> System.out.println("Tools updated: " + tools)))
|
||||
* .resourcesChangeConsumer(resources -> Mono.fromRunnable(() -> System.out.println("Resources updated: " + resources)))
|
||||
* .promptsChangeConsumer(prompts -> Mono.fromRunnable(() -> System.out.println("Prompts updated: " + prompts)))
|
||||
* .loggingConsumer(message -> Mono.fromRunnable(() -> System.out.println("Log message: " + message)))
|
||||
* .build();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>
|
||||
* The client supports:
|
||||
* <ul>
|
||||
* <li>Tool discovery and invocation
|
||||
* <li>Resource access and management
|
||||
* <li>Prompt template handling
|
||||
* <li>Real-time updates through change consumers
|
||||
* <li>Custom sampling strategies
|
||||
* <li>Structured logging with severity levels
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The client supports structured logging through the MCP logging utility:
|
||||
* <ul>
|
||||
* <li>Eight severity levels from DEBUG to EMERGENCY
|
||||
* <li>Optional logger name categorization
|
||||
* <li>Configurable logging consumers
|
||||
* <li>Server-controlled minimum log level
|
||||
* </ul>
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @see McpAsyncClient
|
||||
* @see McpTransport
|
||||
*/
|
||||
public interface McpClient {
|
||||
|
||||
/**
|
||||
* Start building a synchronous MCP client with the specified transport layer. The
|
||||
* synchronous MCP client provides blocking operations. Synchronous clients wait for
|
||||
* each operation to complete before returning, making them simpler to use but
|
||||
* potentially less performant for concurrent operations. The transport layer handles
|
||||
* the low-level communication between client and server using protocols like stdio or
|
||||
* Server-Sent Events (SSE).
|
||||
* @param transport The transport layer implementation for MCP communication. Common
|
||||
* implementations include {@code StdioClientTransport} for stdio-based communication
|
||||
* and {@code SseClientTransport} for SSE-based communication.
|
||||
* @return A new builder instance for configuring the client
|
||||
* @throws IllegalArgumentException if transport is null
|
||||
*/
|
||||
static SyncSpec sync(McpClientTransport transport) {
|
||||
return new SyncSpec(transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start building an asynchronous MCP client with the specified transport layer. The
|
||||
* asynchronous MCP client provides non-blocking operations. Asynchronous clients
|
||||
* return reactive primitives (Mono/Flux) immediately, allowing for concurrent
|
||||
* operations and reactive programming patterns. The transport layer handles the
|
||||
* low-level communication between client and server using protocols like stdio or
|
||||
* Server-Sent Events (SSE).
|
||||
* @param transport The transport layer implementation for MCP communication. Common
|
||||
* implementations include {@code StdioClientTransport} for stdio-based communication
|
||||
* and {@code SseClientTransport} for SSE-based communication.
|
||||
* @return A new builder instance for configuring the client
|
||||
* @throws IllegalArgumentException if transport is null
|
||||
*/
|
||||
static AsyncSpec async(McpClientTransport transport) {
|
||||
return new AsyncSpec(transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous client specification. This class follows the builder pattern to provide
|
||||
* a fluent API for setting up clients with custom configurations.
|
||||
*
|
||||
* <p>
|
||||
* The builder supports configuration of:
|
||||
* <ul>
|
||||
* <li>Transport layer for client-server communication
|
||||
* <li>Request timeouts for operation boundaries
|
||||
* <li>Client capabilities for feature negotiation
|
||||
* <li>Client implementation details for version tracking
|
||||
* <li>Root URIs for resource access
|
||||
* <li>Change notification handlers for tools, resources, and prompts
|
||||
* <li>Custom message sampling logic
|
||||
* </ul>
|
||||
*/
|
||||
class SyncSpec {
|
||||
|
||||
private final McpClientTransport transport;
|
||||
|
||||
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
|
||||
|
||||
private Duration initializationTimeout = Duration.ofSeconds(20);
|
||||
|
||||
private ClientCapabilities capabilities;
|
||||
|
||||
private Implementation clientInfo = new Implementation("Java SDK MCP Client", "1.0.0");
|
||||
|
||||
private final Map<String, Root> roots = new HashMap<>();
|
||||
|
||||
private final List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers = new ArrayList<>();
|
||||
|
||||
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
|
||||
|
||||
private SyncSpec(McpClientTransport transport) {
|
||||
Assert.notNull(transport, "Transport must not be null");
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration to wait for server responses before timing out requests. This
|
||||
* timeout applies to all requests made through the client, including tool calls,
|
||||
* resource access, and prompt operations.
|
||||
* @param requestTimeout The duration to wait before timing out requests. Must not
|
||||
* be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if requestTimeout is null
|
||||
*/
|
||||
public SyncSpec requestTimeout(Duration requestTimeout) {
|
||||
Assert.notNull(requestTimeout, "Request timeout must not be null");
|
||||
this.requestTimeout = requestTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param initializationTimeout The duration to wait for the initializaiton
|
||||
* lifecycle step to complete.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if initializationTimeout is null
|
||||
*/
|
||||
public SyncSpec initializationTimeout(Duration initializationTimeout) {
|
||||
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
|
||||
this.initializationTimeout = initializationTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the client capabilities that will be advertised to the server during
|
||||
* connection initialization. Capabilities define what features the client
|
||||
* supports, such as tool execution, resource access, and prompt handling.
|
||||
* @param capabilities The client capabilities configuration. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if capabilities is null
|
||||
*/
|
||||
public SyncSpec capabilities(ClientCapabilities capabilities) {
|
||||
Assert.notNull(capabilities, "Capabilities must not be null");
|
||||
this.capabilities = capabilities;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the client implementation information that will be shared with the server
|
||||
* during connection initialization. This helps with version compatibility and
|
||||
* debugging.
|
||||
* @param clientInfo The client implementation details including name and version.
|
||||
* Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if clientInfo is null
|
||||
*/
|
||||
public SyncSpec clientInfo(Implementation clientInfo) {
|
||||
Assert.notNull(clientInfo, "Client info must not be null");
|
||||
this.clientInfo = clientInfo;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root URIs that this client can access. Roots define the base URIs for
|
||||
* resources that the client can request from the server. For example, a root
|
||||
* might be "file://workspace" for accessing workspace files.
|
||||
* @param roots A list of root definitions. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if roots is null
|
||||
*/
|
||||
public SyncSpec roots(List<Root> roots) {
|
||||
Assert.notNull(roots, "Roots must not be null");
|
||||
for (Root root : roots) {
|
||||
this.roots.put(root.uri(), root);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root URIs that this client can access, using a varargs parameter for
|
||||
* convenience. This is an alternative to {@link #roots(List)}.
|
||||
* @param roots An array of root definitions. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if roots is null
|
||||
* @see #roots(List)
|
||||
*/
|
||||
public SyncSpec roots(Root... roots) {
|
||||
Assert.notNull(roots, "Roots must not be null");
|
||||
for (Root root : roots) {
|
||||
this.roots.put(root.uri(), root);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom sampling handler for processing message creation requests. The
|
||||
* sampling handler can modify or validate messages before they are sent to the
|
||||
* server, enabling custom processing logic.
|
||||
* @param samplingHandler A function that processes message requests and returns
|
||||
* results. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if samplingHandler is null
|
||||
*/
|
||||
public SyncSpec sampling(Function<CreateMessageRequest, CreateMessageResult> samplingHandler) {
|
||||
Assert.notNull(samplingHandler, "Sampling handler must not be null");
|
||||
this.samplingHandler = samplingHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when the available tools change. This allows the
|
||||
* client to react to changes in the server's tool capabilities, such as tools
|
||||
* being added or removed.
|
||||
* @param toolsChangeConsumer A consumer that receives the updated list of
|
||||
* available tools. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if toolsChangeConsumer is null
|
||||
*/
|
||||
public SyncSpec toolsChangeConsumer(Consumer<List<McpSchema.Tool>> toolsChangeConsumer) {
|
||||
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
|
||||
this.toolsChangeConsumers.add(toolsChangeConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when the available resources change. This allows
|
||||
* the client to react to changes in the server's resource availability, such as
|
||||
* files being added or removed.
|
||||
* @param resourcesChangeConsumer A consumer that receives the updated list of
|
||||
* available resources. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if resourcesChangeConsumer is null
|
||||
*/
|
||||
public SyncSpec resourcesChangeConsumer(Consumer<List<McpSchema.Resource>> resourcesChangeConsumer) {
|
||||
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
|
||||
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when the available prompts change. This allows
|
||||
* the client to react to changes in the server's prompt templates, such as new
|
||||
* templates being added or existing ones being modified.
|
||||
* @param promptsChangeConsumer A consumer that receives the updated list of
|
||||
* available prompts. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if promptsChangeConsumer is null
|
||||
*/
|
||||
public SyncSpec promptsChangeConsumer(Consumer<List<McpSchema.Prompt>> promptsChangeConsumer) {
|
||||
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
|
||||
this.promptsChangeConsumers.add(promptsChangeConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when logging messages are received from the
|
||||
* server. This allows the client to react to log messages, such as warnings or
|
||||
* errors, that are sent by the server.
|
||||
* @param loggingConsumer A consumer that receives logging messages. Must not be
|
||||
* null.
|
||||
* @return This builder instance for method chaining
|
||||
*/
|
||||
public SyncSpec loggingConsumer(Consumer<McpSchema.LoggingMessageNotification> loggingConsumer) {
|
||||
Assert.notNull(loggingConsumer, "Logging consumer must not be null");
|
||||
this.loggingConsumers.add(loggingConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds multiple consumers to be notified when logging messages are received from
|
||||
* the server. This allows the client to react to log messages, such as warnings
|
||||
* or errors, that are sent by the server.
|
||||
* @param loggingConsumers A list of consumers that receive logging messages. Must
|
||||
* not be null.
|
||||
* @return This builder instance for method chaining
|
||||
*/
|
||||
public SyncSpec loggingConsumers(List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers) {
|
||||
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
|
||||
this.loggingConsumers.addAll(loggingConsumers);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronous client specification. This class follows the builder pattern to
|
||||
* provide a fluent API for setting up clients with custom configurations.
|
||||
*
|
||||
* <p>
|
||||
* The builder supports configuration of:
|
||||
* <ul>
|
||||
* <li>Transport layer for client-server communication
|
||||
* <li>Request timeouts for operation boundaries
|
||||
* <li>Client capabilities for feature negotiation
|
||||
* <li>Client implementation details for version tracking
|
||||
* <li>Root URIs for resource access
|
||||
* <li>Change notification handlers for tools, resources, and prompts
|
||||
* <li>Custom message sampling logic
|
||||
* </ul>
|
||||
*/
|
||||
class AsyncSpec {
|
||||
|
||||
private final McpClientTransport transport;
|
||||
|
||||
private Duration requestTimeout = Duration.ofSeconds(20); // Default timeout
|
||||
|
||||
private Duration initializationTimeout = Duration.ofSeconds(20);
|
||||
|
||||
private ClientCapabilities capabilities;
|
||||
|
||||
private Implementation clientInfo = new Implementation("Spring AI MCP Client", "0.3.1");
|
||||
|
||||
private final Map<String, Root> roots = new HashMap<>();
|
||||
|
||||
private final List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
|
||||
|
||||
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
|
||||
|
||||
private AsyncSpec(McpClientTransport transport) {
|
||||
Assert.notNull(transport, "Transport must not be null");
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration to wait for server responses before timing out requests. This
|
||||
* timeout applies to all requests made through the client, including tool calls,
|
||||
* resource access, and prompt operations.
|
||||
* @param requestTimeout The duration to wait before timing out requests. Must not
|
||||
* be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if requestTimeout is null
|
||||
*/
|
||||
public AsyncSpec requestTimeout(Duration requestTimeout) {
|
||||
Assert.notNull(requestTimeout, "Request timeout must not be null");
|
||||
this.requestTimeout = requestTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param initializationTimeout The duration to wait for the initializaiton
|
||||
* lifecycle step to complete.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if initializationTimeout is null
|
||||
*/
|
||||
public AsyncSpec initializationTimeout(Duration initializationTimeout) {
|
||||
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
|
||||
this.initializationTimeout = initializationTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the client capabilities that will be advertised to the server during
|
||||
* connection initialization. Capabilities define what features the client
|
||||
* supports, such as tool execution, resource access, and prompt handling.
|
||||
* @param capabilities The client capabilities configuration. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if capabilities is null
|
||||
*/
|
||||
public AsyncSpec capabilities(ClientCapabilities capabilities) {
|
||||
Assert.notNull(capabilities, "Capabilities must not be null");
|
||||
this.capabilities = capabilities;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the client implementation information that will be shared with the server
|
||||
* during connection initialization. This helps with version compatibility and
|
||||
* debugging.
|
||||
* @param clientInfo The client implementation details including name and version.
|
||||
* Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if clientInfo is null
|
||||
*/
|
||||
public AsyncSpec clientInfo(Implementation clientInfo) {
|
||||
Assert.notNull(clientInfo, "Client info must not be null");
|
||||
this.clientInfo = clientInfo;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root URIs that this client can access. Roots define the base URIs for
|
||||
* resources that the client can request from the server. For example, a root
|
||||
* might be "file://workspace" for accessing workspace files.
|
||||
* @param roots A list of root definitions. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if roots is null
|
||||
*/
|
||||
public AsyncSpec roots(List<Root> roots) {
|
||||
Assert.notNull(roots, "Roots must not be null");
|
||||
for (Root root : roots) {
|
||||
this.roots.put(root.uri(), root);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the root URIs that this client can access, using a varargs parameter for
|
||||
* convenience. This is an alternative to {@link #roots(List)}.
|
||||
* @param roots An array of root definitions. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if roots is null
|
||||
* @see #roots(List)
|
||||
*/
|
||||
public AsyncSpec roots(Root... roots) {
|
||||
Assert.notNull(roots, "Roots must not be null");
|
||||
for (Root root : roots) {
|
||||
this.roots.put(root.uri(), root);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom sampling handler for processing message creation requests. The
|
||||
* sampling handler can modify or validate messages before they are sent to the
|
||||
* server, enabling custom processing logic.
|
||||
* @param samplingHandler A function that processes message requests and returns
|
||||
* results. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if samplingHandler is null
|
||||
*/
|
||||
public AsyncSpec sampling(Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler) {
|
||||
Assert.notNull(samplingHandler, "Sampling handler must not be null");
|
||||
this.samplingHandler = samplingHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when the available tools change. This allows the
|
||||
* client to react to changes in the server's tool capabilities, such as tools
|
||||
* being added or removed.
|
||||
* @param toolsChangeConsumer A consumer that receives the updated list of
|
||||
* available tools. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if toolsChangeConsumer is null
|
||||
*/
|
||||
public AsyncSpec toolsChangeConsumer(Function<List<McpSchema.Tool>, Mono<Void>> toolsChangeConsumer) {
|
||||
Assert.notNull(toolsChangeConsumer, "Tools change consumer must not be null");
|
||||
this.toolsChangeConsumers.add(toolsChangeConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when the available resources change. This allows
|
||||
* the client to react to changes in the server's resource availability, such as
|
||||
* files being added or removed.
|
||||
* @param resourcesChangeConsumer A consumer that receives the updated list of
|
||||
* available resources. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if resourcesChangeConsumer is null
|
||||
*/
|
||||
public AsyncSpec resourcesChangeConsumer(
|
||||
Function<List<McpSchema.Resource>, Mono<Void>> resourcesChangeConsumer) {
|
||||
Assert.notNull(resourcesChangeConsumer, "Resources change consumer must not be null");
|
||||
this.resourcesChangeConsumers.add(resourcesChangeConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when the available prompts change. This allows
|
||||
* the client to react to changes in the server's prompt templates, such as new
|
||||
* templates being added or existing ones being modified.
|
||||
* @param promptsChangeConsumer A consumer that receives the updated list of
|
||||
* available prompts. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if promptsChangeConsumer is null
|
||||
*/
|
||||
public AsyncSpec promptsChangeConsumer(Function<List<McpSchema.Prompt>, Mono<Void>> promptsChangeConsumer) {
|
||||
Assert.notNull(promptsChangeConsumer, "Prompts change consumer must not be null");
|
||||
this.promptsChangeConsumers.add(promptsChangeConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified when logging messages are received from the
|
||||
* server. This allows the client to react to log messages, such as warnings or
|
||||
* errors, that are sent by the server.
|
||||
* @param loggingConsumer A consumer that receives logging messages. Must not be
|
||||
* null.
|
||||
* @return This builder instance for method chaining
|
||||
*/
|
||||
public AsyncSpec loggingConsumer(Function<McpSchema.LoggingMessageNotification, Mono<Void>> loggingConsumer) {
|
||||
Assert.notNull(loggingConsumer, "Logging consumer must not be null");
|
||||
this.loggingConsumers.add(loggingConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds multiple consumers to be notified when logging messages are received from
|
||||
* the server. This allows the client to react to log messages, such as warnings
|
||||
* or errors, that are sent by the server.
|
||||
* @param loggingConsumers A list of consumers that receive logging messages. Must
|
||||
* not be null.
|
||||
* @return This builder instance for method chaining
|
||||
*/
|
||||
public AsyncSpec loggingConsumers(
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
|
||||
Assert.notNull(loggingConsumers, "Logging consumers must not be null");
|
||||
this.loggingConsumers.addAll(loggingConsumers);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of {@link McpAsyncClient} with the provided configurations
|
||||
* or sensible defaults.
|
||||
* @return a new instance of {@link McpAsyncClient}.
|
||||
*/
|
||||
public McpAsyncClient build() {
|
||||
return new McpAsyncClient(this.transport, this.requestTimeout, this.initializationTimeout,
|
||||
new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots,
|
||||
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.promptsChangeConsumers,
|
||||
this.loggingConsumers, this.samplingHandler));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
/**
|
||||
* Representation of features and capabilities for Model Context Protocol (MCP) clients.
|
||||
* This class provides two record types for managing client features:
|
||||
* <ul>
|
||||
* <li>{@link Async} for non-blocking operations with Project Reactor's Mono responses
|
||||
* <li>{@link Sync} for blocking operations with direct responses
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Each feature specification includes:
|
||||
* <ul>
|
||||
* <li>Client implementation information and capabilities
|
||||
* <li>Root URI mappings for resource access
|
||||
* <li>Change notification handlers for tools, resources, and prompts
|
||||
* <li>Logging message consumers
|
||||
* <li>Message sampling handlers for request processing
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The class supports conversion between synchronous and asynchronous specifications
|
||||
* through the {@link Async#fromSync} method, which ensures proper handling of blocking
|
||||
* operations in non-blocking contexts by scheduling them on a bounded elastic scheduler.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @see McpClient
|
||||
* @see McpSchema.Implementation
|
||||
* @see McpSchema.ClientCapabilities
|
||||
*/
|
||||
class McpClientFeatures {
|
||||
|
||||
/**
|
||||
* Asynchronous client features specification providing the capabilities and request
|
||||
* and notification handlers.
|
||||
*
|
||||
* @param clientInfo the client implementation information.
|
||||
* @param clientCapabilities the client capabilities.
|
||||
* @param roots the roots.
|
||||
* @param toolsChangeConsumers the tools change consumers.
|
||||
* @param resourcesChangeConsumers the resources change consumers.
|
||||
* @param promptsChangeConsumers the prompts change consumers.
|
||||
* @param loggingConsumers the logging consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
*/
|
||||
record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
|
||||
Map<String, McpSchema.Root> roots, List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
|
||||
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler) {
|
||||
|
||||
/**
|
||||
* Create an instance and validate the arguments.
|
||||
* @param clientCapabilities the client capabilities.
|
||||
* @param roots the roots.
|
||||
* @param toolsChangeConsumers the tools change consumers.
|
||||
* @param resourcesChangeConsumers the resources change consumers.
|
||||
* @param promptsChangeConsumers the prompts change consumers.
|
||||
* @param loggingConsumers the logging consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
*/
|
||||
public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
|
||||
Map<String, McpSchema.Root> roots,
|
||||
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers,
|
||||
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers,
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler) {
|
||||
|
||||
Assert.notNull(clientInfo, "Client info must not be null");
|
||||
this.clientInfo = clientInfo;
|
||||
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
|
||||
: new McpSchema.ClientCapabilities(null,
|
||||
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
|
||||
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null);
|
||||
this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>();
|
||||
|
||||
this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of();
|
||||
this.resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of();
|
||||
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
|
||||
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
|
||||
this.samplingHandler = samplingHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a synchronous specification into an asynchronous one and provide
|
||||
* blocking code offloading to prevent accidental blocking of the non-blocking
|
||||
* transport.
|
||||
* @param syncSpec a potentially blocking, synchronous specification.
|
||||
* @return a specification which is protected from blocking calls specified by the
|
||||
* user.
|
||||
*/
|
||||
public static Async fromSync(Sync syncSpec) {
|
||||
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers = new ArrayList<>();
|
||||
for (Consumer<List<McpSchema.Tool>> consumer : syncSpec.toolsChangeConsumers()) {
|
||||
toolsChangeConsumers.add(t -> Mono.<Void>fromRunnable(() -> consumer.accept(t))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers = new ArrayList<>();
|
||||
for (Consumer<List<McpSchema.Resource>> consumer : syncSpec.resourcesChangeConsumers()) {
|
||||
resourcesChangeConsumers.add(r -> Mono.<Void>fromRunnable(() -> consumer.accept(r))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers = new ArrayList<>();
|
||||
|
||||
for (Consumer<List<McpSchema.Prompt>> consumer : syncSpec.promptsChangeConsumers()) {
|
||||
promptsChangeConsumers.add(p -> Mono.<Void>fromRunnable(() -> consumer.accept(p))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers = new ArrayList<>();
|
||||
for (Consumer<McpSchema.LoggingMessageNotification> consumer : syncSpec.loggingConsumers()) {
|
||||
loggingConsumers.add(l -> Mono.<Void>fromRunnable(() -> consumer.accept(l))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = r -> Mono
|
||||
.fromCallable(() -> syncSpec.samplingHandler().apply(r))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(),
|
||||
toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers, loggingConsumers,
|
||||
samplingHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous client features specification providing the capabilities and request
|
||||
* and notification handlers.
|
||||
*
|
||||
* @param clientInfo the client implementation information.
|
||||
* @param clientCapabilities the client capabilities.
|
||||
* @param roots the roots.
|
||||
* @param toolsChangeConsumers the tools change consumers.
|
||||
* @param resourcesChangeConsumers the resources change consumers.
|
||||
* @param promptsChangeConsumers the prompts change consumers.
|
||||
* @param loggingConsumers the logging consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
*/
|
||||
public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
|
||||
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
|
||||
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler) {
|
||||
|
||||
/**
|
||||
* Create an instance and validate the arguments.
|
||||
* @param clientInfo the client implementation information.
|
||||
* @param clientCapabilities the client capabilities.
|
||||
* @param roots the roots.
|
||||
* @param toolsChangeConsumers the tools change consumers.
|
||||
* @param resourcesChangeConsumers the resources change consumers.
|
||||
* @param promptsChangeConsumers the prompts change consumers.
|
||||
* @param loggingConsumers the logging consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
*/
|
||||
public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities,
|
||||
Map<String, McpSchema.Root> roots, List<Consumer<List<McpSchema.Tool>>> toolsChangeConsumers,
|
||||
List<Consumer<List<McpSchema.Resource>>> resourcesChangeConsumers,
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler) {
|
||||
|
||||
Assert.notNull(clientInfo, "Client info must not be null");
|
||||
this.clientInfo = clientInfo;
|
||||
this.clientCapabilities = (clientCapabilities != null) ? clientCapabilities
|
||||
: new McpSchema.ClientCapabilities(null,
|
||||
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
|
||||
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null);
|
||||
this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>();
|
||||
|
||||
this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of();
|
||||
this.resourcesChangeConsumers = resourcesChangeConsumers != null ? resourcesChangeConsumers : List.of();
|
||||
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
|
||||
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
|
||||
this.samplingHandler = samplingHandler;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package com.xspaceagi.mcp.infra.client;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSession;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.MonoSink;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Default implementation of the MCP (Model Context Protocol) session that manages
|
||||
* bidirectional JSON-RPC communication between clients and servers. This implementation
|
||||
* follows the MCP specification for message exchange and transport handling.
|
||||
*
|
||||
* <p>
|
||||
* The session manages:
|
||||
* <ul>
|
||||
* <li>Request/response handling with unique message IDs</li>
|
||||
* <li>Notification processing</li>
|
||||
* <li>Message timeout management</li>
|
||||
* <li>Transport layer abstraction</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class McpClientSession implements McpSession {
|
||||
|
||||
/**
|
||||
* Logger for this class
|
||||
*/
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpClientSession.class);
|
||||
|
||||
/**
|
||||
* Duration to wait for request responses before timing out
|
||||
*/
|
||||
private final Duration requestTimeout;
|
||||
|
||||
/**
|
||||
* Transport layer implementation for message exchange
|
||||
*/
|
||||
private final McpClientTransport transport;
|
||||
|
||||
/**
|
||||
* Map of pending responses keyed by request ID
|
||||
*/
|
||||
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Map of request handlers keyed by method name
|
||||
*/
|
||||
private final ConcurrentHashMap<String, RequestHandler<?>> requestHandlers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Map of notification handlers keyed by method name
|
||||
*/
|
||||
private final ConcurrentHashMap<String, NotificationHandler> notificationHandlers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Session-specific prefix for request IDs
|
||||
*/
|
||||
private final String sessionPrefix = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
/**
|
||||
* Atomic counter for generating unique request IDs
|
||||
*/
|
||||
private final AtomicLong requestCounter = new AtomicLong(0);
|
||||
|
||||
private final Disposable connection;
|
||||
|
||||
/**
|
||||
* Functional interface for handling incoming JSON-RPC requests. Implementations
|
||||
* should process the request parameters and return a response.
|
||||
*
|
||||
* @param <T> Response type
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RequestHandler<T> {
|
||||
|
||||
/**
|
||||
* Handles an incoming request with the given parameters.
|
||||
*
|
||||
* @param params The request parameters
|
||||
* @return A Mono containing the response object
|
||||
*/
|
||||
Mono<T> handle(Object params);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional interface for handling incoming JSON-RPC notifications. Implementations
|
||||
* should process the notification parameters without returning a response.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface NotificationHandler {
|
||||
|
||||
/**
|
||||
* Handles an incoming notification with the given parameters.
|
||||
*
|
||||
* @param params The notification parameters
|
||||
* @return A Mono that completes when the notification is processed
|
||||
*/
|
||||
Mono<Void> handle(Object params);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new McpClientSession with the specified configuration and handlers.
|
||||
*
|
||||
* @param requestTimeout Duration to wait for responses
|
||||
* @param transport Transport implementation for message exchange
|
||||
* @param requestHandlers Map of method names to request handlers
|
||||
* @param notificationHandlers Map of method names to notification handlers
|
||||
*/
|
||||
public McpClientSession(Duration requestTimeout, McpClientTransport transport,
|
||||
Map<String, RequestHandler<?>> requestHandlers, Map<String, NotificationHandler> notificationHandlers) {
|
||||
|
||||
Assert.notNull(requestTimeout, "The requestTimeout can not be null");
|
||||
Assert.notNull(transport, "The transport can not be null");
|
||||
Assert.notNull(requestHandlers, "The requestHandlers can not be null");
|
||||
Assert.notNull(notificationHandlers, "The notificationHandlers can not be null");
|
||||
|
||||
this.requestTimeout = requestTimeout;
|
||||
this.transport = transport;
|
||||
this.requestHandlers.putAll(requestHandlers);
|
||||
this.notificationHandlers.putAll(notificationHandlers);
|
||||
|
||||
// TODO: consider mono.transformDeferredContextual where the Context contains
|
||||
// the
|
||||
// Observation associated with the individual message - it can be used to
|
||||
// create child Observation and emit it together with the message to the
|
||||
// consumer
|
||||
this.connection = this.transport.connect(mono -> mono.doOnNext(message -> {
|
||||
if (message instanceof McpSchema.JSONRPCResponse response) {
|
||||
logger.debug("Received Response: {}", response);
|
||||
var sink = pendingResponses.remove(response.id());
|
||||
if (sink == null) {
|
||||
logger.warn("Unexpected response for unknown id {}", response.id());
|
||||
} else {
|
||||
sink.success(response);
|
||||
}
|
||||
} else if (message instanceof McpSchema.JSONRPCRequest request) {
|
||||
logger.debug("Received request: {}", request);
|
||||
handleIncomingRequest(request).subscribe(response -> transport.sendMessage(response).subscribe(),
|
||||
error -> {
|
||||
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
|
||||
null, new McpSchema.JSONRPCResponse.JSONRPCError(
|
||||
McpSchema.ErrorCodes.INTERNAL_ERROR, error.getMessage(), null));
|
||||
transport.sendMessage(errorResponse).subscribe();
|
||||
});
|
||||
} else if (message instanceof McpSchema.JSONRPCNotification notification) {
|
||||
logger.debug("Received notification: {}", notification);
|
||||
handleIncomingNotification(notification).subscribe(null,
|
||||
error -> logger.error("Error handling notification: {}", error.getMessage()));
|
||||
}
|
||||
}).doOnError(error -> {
|
||||
logger.error("Error handling message: {}", error.getMessage());
|
||||
close();
|
||||
})).subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming JSON-RPC request by routing it to the appropriate handler.
|
||||
*
|
||||
* @param request The incoming JSON-RPC request
|
||||
* @return A Mono containing the JSON-RPC response
|
||||
*/
|
||||
private Mono<McpSchema.JSONRPCResponse> handleIncomingRequest(McpSchema.JSONRPCRequest request) {
|
||||
return Mono.defer(() -> {
|
||||
var handler = this.requestHandlers.get(request.method());
|
||||
if (handler == null) {
|
||||
MethodNotFoundError error = getMethodNotFoundError(request.method());
|
||||
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
|
||||
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
|
||||
error.message(), error.data())));
|
||||
}
|
||||
|
||||
return handler.handle(request.params())
|
||||
.map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
|
||||
.onErrorResume(error -> Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
|
||||
null, new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
|
||||
error.getMessage(), null)))); // TODO: add error message
|
||||
// through the data field
|
||||
});
|
||||
}
|
||||
|
||||
record MethodNotFoundError(String method, String message, Object data) {
|
||||
}
|
||||
|
||||
public static MethodNotFoundError getMethodNotFoundError(String method) {
|
||||
switch (method) {
|
||||
case McpSchema.METHOD_ROOTS_LIST:
|
||||
return new MethodNotFoundError(method, "Roots not supported",
|
||||
Map.of("reason", "Client does not have roots capability"));
|
||||
default:
|
||||
return new MethodNotFoundError(method, "Method not found: " + method, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming JSON-RPC notification by routing it to the appropriate handler.
|
||||
*
|
||||
* @param notification The incoming JSON-RPC notification
|
||||
* @return A Mono that completes when the notification is processed
|
||||
*/
|
||||
private Mono<Void> handleIncomingNotification(McpSchema.JSONRPCNotification notification) {
|
||||
return Mono.defer(() -> {
|
||||
var handler = notificationHandlers.get(notification.method());
|
||||
if (handler == null) {
|
||||
logger.error("No handler registered for notification method: {}", notification.method());
|
||||
return Mono.empty();
|
||||
}
|
||||
return handler.handle(notification.params());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique request ID in a non-blocking way. Combines a session-specific
|
||||
* prefix with an atomic counter to ensure uniqueness.
|
||||
*
|
||||
* @return A unique request ID string
|
||||
*/
|
||||
private String generateRequestId() {
|
||||
return this.sessionPrefix + "-" + this.requestCounter.getAndIncrement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC request and returns the response.
|
||||
*
|
||||
* @param <T> The expected response type
|
||||
* @param method The method name to call
|
||||
* @param requestParams The request parameters
|
||||
* @param typeRef Type reference for response deserialization
|
||||
* @return A Mono containing the response
|
||||
*/
|
||||
@Override
|
||||
public <T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
|
||||
String requestId = this.generateRequestId();
|
||||
|
||||
return Mono.<McpSchema.JSONRPCResponse>create(sink -> {
|
||||
this.pendingResponses.put(requestId, sink);
|
||||
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method,
|
||||
requestId, requestParams);
|
||||
this.transport.sendMessage(jsonrpcRequest)
|
||||
// TODO: It's most efficient to create a dedicated Subscriber here
|
||||
.subscribe(v -> {
|
||||
}, error -> {
|
||||
this.pendingResponses.remove(requestId);
|
||||
sink.error(error);
|
||||
});
|
||||
}).timeout(this.requestTimeout).handle((jsonRpcResponse, sink) -> {
|
||||
if (jsonRpcResponse.error() != null) {
|
||||
sink.error(new McpError(jsonRpcResponse.error()));
|
||||
} else {
|
||||
if (typeRef.getType().equals(Void.class)) {
|
||||
sink.complete();
|
||||
} else {
|
||||
sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a JSON-RPC notification.
|
||||
*
|
||||
* @param method The method name for the notification
|
||||
* @param params The notification parameters
|
||||
* @return A Mono that completes when the notification is sent
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> sendNotification(String method, Object params) {
|
||||
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||
method, params);
|
||||
return this.transport.sendMessage(jsonrpcNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the session gracefully, allowing pending operations to complete.
|
||||
*
|
||||
* @return A Mono that completes when the session is closed
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
pendingResponses.forEach((id, sink) -> sink.error(new Exception("Session closed")));
|
||||
return Mono.defer(() -> {
|
||||
this.connection.dispose();
|
||||
return transport.closeGracefully();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the session immediately, potentially interrupting pending operations.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
this.connection.dispose();
|
||||
transport.close();
|
||||
pendingResponses.forEach((id, sink) -> sink.error(new Exception("Session closed")));
|
||||
pendingResponses.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xspaceagi.mcp.infra.dao.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface McpConfigMapper extends BaseMapper<McpConfig> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.xspaceagi.mcp.infra.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.xspaceagi.mcp.adapter.repository.McpConfigRepository;
|
||||
import com.xspaceagi.mcp.adapter.repository.entity.McpConfig;
|
||||
import com.xspaceagi.mcp.infra.dao.mapper.McpConfigMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class McpConfigRepositoryImpl extends ServiceImpl<McpConfigMapper, McpConfig> implements McpConfigRepository {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.xspaceagi.mcp.infra.rpc;
|
||||
|
||||
import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse;
|
||||
import com.xspaceagi.eco.market.sdk.request.ClientSecretRequest;
|
||||
import com.xspaceagi.eco.market.sdk.service.IEcoMarketRpcService;
|
||||
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MarketplaceRpcService {
|
||||
|
||||
@Resource
|
||||
private IEcoMarketRpcService ecoMarketRpcService;
|
||||
|
||||
|
||||
public ClientSecretResponse queryClientSecret(ClientSecretRequest request) {
|
||||
Object clientSecret = SimpleJvmHashCache.getHash("client_secret", request.getTenantId().toString());
|
||||
if (clientSecret != null) {
|
||||
return (ClientSecretResponse) clientSecret;
|
||||
}
|
||||
ClientSecretResponse clientSecretResponse = ecoMarketRpcService.queryClientSecret(request);
|
||||
if (clientSecretResponse != null){
|
||||
SimpleJvmHashCache.putHash("client_secret", request.getTenantId().toString(), clientSecretResponse, 7200);
|
||||
return clientSecretResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.xspaceagi.mcp.infra.rpc;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.xspaceagi.eco.market.sdk.reponse.ClientSecretResponse;
|
||||
import com.xspaceagi.eco.market.sdk.request.ClientSecretRequest;
|
||||
import com.xspaceagi.mcp.infra.client.HttpClientSseClientTransport;
|
||||
import com.xspaceagi.mcp.infra.client.McpAsyncClient;
|
||||
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
|
||||
import com.xspaceagi.mcp.infra.client.McpClient;
|
||||
import com.xspaceagi.mcp.infra.rpc.dto.McpDeployStatusResponse;
|
||||
import com.xspaceagi.mcp.infra.rpc.enums.McpPersistentTypeEnum;
|
||||
import com.xspaceagi.mcp.spec.utils.UrlExtractUtil;
|
||||
import com.xspaceagi.system.spec.cache.SimpleJvmHashCache;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.spec.utils.HttpClient;
|
||||
import com.xspaceagi.system.spec.utils.MD5;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.codec.binary.Base64;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.nio.charset.Charset;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class McpDeployRpcService {
|
||||
|
||||
static {
|
||||
System.setProperty("jdk.httpclient.keepalive.timeout", "0");
|
||||
}
|
||||
|
||||
private static final String MCP_VERSION_KEY = "mcp-version:";
|
||||
@Value("${mcp.proxy-base-url:}")
|
||||
private String mcpProxyBaseUrl;
|
||||
|
||||
@Resource
|
||||
private HttpClient httpClient;
|
||||
|
||||
@Resource
|
||||
private MarketplaceRpcService marketplaceRpcService;
|
||||
|
||||
private java.net.http.HttpClient sseHttpClient = java.net.http.HttpClient.newBuilder()
|
||||
.version(java.net.http.HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10)).build();
|
||||
|
||||
public McpDeployStatusResponse deploy(String mcpId, String serverConfig, McpPersistentTypeEnum persistentType) {
|
||||
String statusApi = mcpProxyBaseUrl + "/mcp/sse/check_status";
|
||||
String mcpKey = getDeployMcpKey(mcpId, serverConfig);
|
||||
JSONObject params = new JSONObject();
|
||||
params.put("mcpId", mcpKey);
|
||||
params.put("mcpType", persistentType.name());
|
||||
params.put("mcpJsonConfig", JSON.parseObject(serverConfig).toJSONString());
|
||||
String content = httpClient.post(statusApi, params.toJSONString(), new HashMap<>());
|
||||
log.debug("Mcp deploy result: {}", content);
|
||||
ReqResult result = JSON.parseObject(content, ReqResult.class);
|
||||
if (!result.isSuccess()) {
|
||||
log.warn("Mcp deploy failed: {}", result.getMessage());
|
||||
throw BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.remoteServiceMessage,
|
||||
result.getMessage() != null ? result.getMessage() : "");
|
||||
}
|
||||
McpDeployStatusResponse mcpDeployStatusResponse = ((JSONObject) result.getData()).toJavaObject(McpDeployStatusResponse.class);
|
||||
if (mcpDeployStatusResponse == null) {
|
||||
throw BizException.of(ErrorCodeEnum.SYS_ERROR, BizExceptionCodeEnum.mcpDeployResponseNull);
|
||||
}
|
||||
return mcpDeployStatusResponse;
|
||||
}
|
||||
|
||||
public Mono<McpAsyncClientWrapper> getMcpAsyncClient(String mcpId, String conversationId, String serverConfig) {
|
||||
Object val = SimpleJvmHashCache.removeHash(getPoolKey(mcpId), conversationId);
|
||||
if (val != null) {
|
||||
return Mono.just((McpAsyncClientWrapper) val);
|
||||
}
|
||||
Object mcpKey = getMcpKey(mcpId, conversationId);
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mcp-json", Base64.encodeBase64String(serverConfig.getBytes(Charset.forName("UTF-8"))))//"{\"mcpServers\":{\"@upstash/context7-mcp\":{\"command\":\"npx1\",\"args\":[\"-y\",\"@mcp_hub_org/cli@latest\",\"run\",\"@upstash/context7-mcp\"]}}}")//
|
||||
.header("x-mcp-type", "OneShot");
|
||||
HttpClientSseClientTransport.Builder httpClientSseClientTransportBuilder = HttpClientSseClientTransport.builder(mcpProxyBaseUrl)
|
||||
.sseEndpoint("/mcp/sse/proxy/" + mcpKey + "/sse")
|
||||
.httpClient(sseHttpClient)
|
||||
.requestBuilder(builder);
|
||||
return buildClientWrapper(httpClientSseClientTransportBuilder, mcpId, serverConfig);
|
||||
}
|
||||
|
||||
public Mono<McpAsyncClientWrapper> getMcpAsyncClientForSSE(String mcpId, String conversationId, String serverConfig) {
|
||||
//从serverConfig中解析出sse地址
|
||||
List<String> list = UrlExtractUtil.extractUrls(serverConfig);
|
||||
if (list.size() == 0) {
|
||||
return Mono.error(new IllegalArgumentException("Invalid serverConfig"));
|
||||
}
|
||||
Object val = SimpleJvmHashCache.removeHash(getPoolKey(mcpId), conversationId);
|
||||
if (val != null) {
|
||||
return Mono.just((McpAsyncClientWrapper) val);
|
||||
}
|
||||
|
||||
String baseUrl;
|
||||
String endpoint;
|
||||
try {
|
||||
URL urlObj = new URL(list.get(0));
|
||||
// 1. 获取根地址(协议 + 域名 + 端口)
|
||||
String protocol = urlObj.getProtocol();
|
||||
String host = urlObj.getHost();
|
||||
int port = urlObj.getPort();
|
||||
baseUrl = protocol + "://" + host;
|
||||
if (port != -1) {
|
||||
baseUrl += ":" + port;
|
||||
}
|
||||
// 2. 获取带参数的URI路径
|
||||
String path = urlObj.getPath();
|
||||
String query = urlObj.getQuery();
|
||||
String fullUri = path;
|
||||
if (query != null) {
|
||||
fullUri += "?" + query;
|
||||
}
|
||||
|
||||
endpoint = fullUri;
|
||||
try {
|
||||
ClientSecretResponse clientSecretResponse = marketplaceRpcService.queryClientSecret(new ClientSecretRequest(RequestContext.get().getTenantId()));
|
||||
if (clientSecretResponse != null) {
|
||||
endpoint = endpoint.replace("TENANT_SECRET", clientSecretResponse.getClientSecret());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
log.warn("queryClientSecret error", e);
|
||||
}
|
||||
} catch (MalformedURLException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
HttpClientSseClientTransport.Builder builder = HttpClientSseClientTransport.builder(baseUrl);
|
||||
builder.sseEndpoint(endpoint);
|
||||
builder.httpClient(sseHttpClient);
|
||||
try {
|
||||
Map<String, String> headers = UrlExtractUtil.extractHeaders(serverConfig);
|
||||
if (headers != null && headers.size() > 0) {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
|
||||
requestBuilder.header("Content-Type", "application/json");
|
||||
headers.forEach((k, v) -> requestBuilder.header(k, v));
|
||||
builder.requestBuilder(requestBuilder);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
log.warn("extractHeaders error", e);
|
||||
}
|
||||
return buildClientWrapper(builder, mcpId, serverConfig);
|
||||
}
|
||||
|
||||
private Mono<McpAsyncClientWrapper> buildClientWrapper(HttpClientSseClientTransport.Builder builder, String mcpId, String serverConfig) {
|
||||
return Mono.create(emitter -> {
|
||||
HttpClientSseClientTransport httpClientSseClientTransport = builder.build();
|
||||
McpAsyncClient client = McpClient.async(httpClientSseClientTransport).requestTimeout(Duration.ofMinutes(30)).build();
|
||||
client.initialize().timeout(Duration.ofSeconds(20))
|
||||
.onErrorResume(throwable -> {
|
||||
if (throwable instanceof TimeoutException) {
|
||||
log.warn("MCP {} initialize timeout", mcpId);
|
||||
return Mono.error(new McpError("MCP initialize timeout"));
|
||||
}
|
||||
return Mono.error(new McpError(throwable.getMessage()));
|
||||
}).doOnError((throwable) -> {
|
||||
log.warn("MCP {} initialize failed", mcpId);
|
||||
httpClientSseClientTransport.close();
|
||||
client.close();
|
||||
if (throwable instanceof McpError) {
|
||||
emitter.error(throwable);
|
||||
return;
|
||||
}
|
||||
emitter.error(new McpError("MCP initialize failed"));
|
||||
}).doOnSuccess(result -> {
|
||||
log.info("MCP {} initialized: {}", mcpId, serverConfig);
|
||||
McpAsyncClientWrapper clientWrapper = McpAsyncClientWrapper.builder().httpClientSseClientTransport(httpClientSseClientTransport)
|
||||
.client(client).build();
|
||||
emitter.success(clientWrapper);
|
||||
}).subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private String getMcpKey(String mcpId, String conversationId) {
|
||||
return mcpId + "-" + conversationId;
|
||||
}
|
||||
|
||||
public void returnMcpClient(String mcpId, String conversationId, McpAsyncClientWrapper mcpAsyncClientWrapper) {
|
||||
log.debug("returnMcpClient: {} {} {}", mcpId, conversationId, mcpAsyncClientWrapper);
|
||||
if (conversationId == null) {
|
||||
mcpAsyncClientWrapper.close();
|
||||
return;
|
||||
}
|
||||
Object val = SimpleJvmHashCache.getHash(getPoolKey(mcpId), conversationId);
|
||||
if (val != mcpAsyncClientWrapper) {
|
||||
try {
|
||||
((McpAsyncClient) val).close();
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
//缓存30秒,没有再使用就关闭;
|
||||
SimpleJvmHashCache.putHash(getPoolKey(mcpId), conversationId, mcpAsyncClientWrapper, 30, (mcpClient) -> {
|
||||
mcpAsyncClientWrapper.close();
|
||||
});
|
||||
}
|
||||
|
||||
public void closeMcpClient(String mcpId, String conversationId, McpAsyncClientWrapper mcpAsyncClientWrapper) {
|
||||
log.debug("closeMcpClient: {} {} {}", mcpId, conversationId, mcpAsyncClientWrapper);
|
||||
if (mcpAsyncClientWrapper == null) {
|
||||
mcpAsyncClientWrapper = (McpAsyncClientWrapper) SimpleJvmHashCache.getHash(getPoolKey(mcpId), conversationId);
|
||||
}
|
||||
SimpleJvmHashCache.removeHash(getPoolKey(mcpId), conversationId);
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
mcpAsyncClientWrapper.close();
|
||||
String deleteApi = mcpProxyBaseUrl + "/mcp/config/delete/{mcp_id}";
|
||||
httpClient.delete(deleteApi.replace("{mcp_id}", getMcpKey(mcpId, conversationId)), new HashMap<>());
|
||||
}
|
||||
}
|
||||
|
||||
private String getDeployMcpKey(String mcpId, String serverConfig) {
|
||||
return getMcpKey(mcpId, MD5.MD5Encode(serverConfig));
|
||||
}
|
||||
|
||||
private String getPoolKey(String mcpId) {
|
||||
return MCP_VERSION_KEY + mcpId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.xspaceagi.mcp.infra.rpc.dto;
|
||||
|
||||
import com.xspaceagi.mcp.infra.rpc.enums.McpDeployStatusEnum;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class McpDeployStatusResponse {
|
||||
|
||||
private Boolean ready;
|
||||
private McpDeployStatusEnum status;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.xspaceagi.mcp.infra.rpc.enums;
|
||||
|
||||
public enum McpDeployStatusEnum {
|
||||
Ready, Pending, Error
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.xspaceagi.mcp.infra.rpc.enums;
|
||||
|
||||
public enum McpPersistentTypeEnum {
|
||||
Persistent, OneShot
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
|
||||
import io.modelcontextprotocol.spec.McpClientSession;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import static io.modelcontextprotocol.spec.McpSchema.*;
|
||||
|
||||
/**
|
||||
* The Model Context Protocol (MCP) server implementation that provides asynchronous
|
||||
* communication using Project Reactor's Mono and Flux types.
|
||||
*
|
||||
* <p>
|
||||
* This server implements the MCP specification, enabling AI models to expose tools,
|
||||
* resources, and prompts through a standardized interface. Key features include:
|
||||
* <ul>
|
||||
* <li>Asynchronous communication using reactive programming patterns
|
||||
* <li>Dynamic tool registration and management
|
||||
* <li>Resource handling with URI-based addressing
|
||||
* <li>Prompt template management
|
||||
* <li>Real-time client notifications for state changes
|
||||
* <li>Structured logging with configurable severity levels
|
||||
* <li>Support for client-side AI model sampling
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The server follows a lifecycle:
|
||||
* <ol>
|
||||
* <li>Initialization - Accepts client connections and negotiates capabilities
|
||||
* <li>Normal Operation - Handles client requests and sends notifications
|
||||
* <li>Graceful Shutdown - Ensures clean connection termination
|
||||
* </ol>
|
||||
*
|
||||
* <p>
|
||||
* This implementation uses Project Reactor for non-blocking operations, making it
|
||||
* suitable for high-throughput scenarios and reactive applications. All operations return
|
||||
* Mono or Flux types that can be composed into reactive pipelines.
|
||||
*
|
||||
* <p>
|
||||
* The server supports runtime modification of its capabilities through methods like
|
||||
* {@link #addTool}, {@link #addResource}, and {@link #addPrompt}, automatically notifying
|
||||
* connected clients of changes when configured to do so.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @see McpServer
|
||||
* @see McpSchema
|
||||
* @see McpClientSession
|
||||
*/
|
||||
public class McpAsyncServer {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpAsyncServer.class);
|
||||
|
||||
private final McpAsyncServer delegate;
|
||||
|
||||
protected Mono<McpAsyncClientWrapper> mcpAsyncClientWrapperMono;
|
||||
|
||||
protected McpAsyncClientWrapper mcpAsyncClientWrapper;
|
||||
|
||||
McpAsyncServer() {
|
||||
this.delegate = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new McpAsyncServer with the given transport provider and capabilities.
|
||||
*
|
||||
* @param mcpTransportProvider The transport layer implementation for MCP
|
||||
* communication.
|
||||
* @param features The MCP server supported features.
|
||||
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
|
||||
*/
|
||||
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
|
||||
McpServerFeatures.Async features, boolean isProxy) {
|
||||
this.delegate = new AsyncServerImpl(mcpTransportProvider, objectMapper, features, isProxy);
|
||||
}
|
||||
|
||||
public void setMcpProxyAsyncClientMono(Mono<McpAsyncClientWrapper> mcpAsyncClientWrapperMono) {
|
||||
this.delegate.mcpAsyncClientWrapperMono = mcpAsyncClientWrapperMono;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server capabilities that define the supported features and functionality.
|
||||
*
|
||||
* @return The server capabilities
|
||||
*/
|
||||
public McpSchema.ServerCapabilities getServerCapabilities() {
|
||||
return this.delegate.getServerCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server implementation information.
|
||||
*
|
||||
* @return The server implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getServerInfo() {
|
||||
return this.delegate.getServerInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the server, allowing any in-progress operations to complete.
|
||||
*
|
||||
* @return A Mono that completes when the server has been closed
|
||||
*/
|
||||
public Mono<Void> closeGracefully() {
|
||||
return this.delegate.closeGracefully();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the server immediately.
|
||||
*/
|
||||
public void close() {
|
||||
this.delegate.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tool Management
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* Add a new tool specification at runtime.
|
||||
*
|
||||
* @param toolSpecification The tool specification to add
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
public Mono<Void> addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
|
||||
return this.delegate.addTool(toolSpecification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tool handler at runtime.
|
||||
*
|
||||
* @param toolName The name of the tool handler to remove
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
public Mono<Void> removeTool(String toolName) {
|
||||
return this.delegate.removeTool(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies clients that the list of available tools has changed.
|
||||
*
|
||||
* @return A Mono that completes when all clients have been notified
|
||||
*/
|
||||
public Mono<Void> notifyToolsListChanged() {
|
||||
return this.delegate.notifyToolsListChanged();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resource Management
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* Add a new resource handler at runtime.
|
||||
*
|
||||
* @param resourceHandler The resource handler to add
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
public Mono<Void> addResource(McpServerFeatures.AsyncResourceSpecification resourceHandler) {
|
||||
return this.delegate.addResource(resourceHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a resource handler at runtime.
|
||||
*
|
||||
* @param resourceUri The URI of the resource handler to remove
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
public Mono<Void> removeResource(String resourceUri) {
|
||||
return this.delegate.removeResource(resourceUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies clients that the list of available resources has changed.
|
||||
*
|
||||
* @return A Mono that completes when all clients have been notified
|
||||
*/
|
||||
public Mono<Void> notifyResourcesListChanged() {
|
||||
return this.delegate.notifyResourcesListChanged();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Prompt Management
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* Add a new prompt handler at runtime.
|
||||
*
|
||||
* @param promptSpecification The prompt handler to add
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
public Mono<Void> addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
|
||||
return this.delegate.addPrompt(promptSpecification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a prompt handler at runtime.
|
||||
*
|
||||
* @param promptName The name of the prompt handler to remove
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
public Mono<Void> removePrompt(String promptName) {
|
||||
return this.delegate.removePrompt(promptName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies clients that the list of available prompts has changed.
|
||||
*
|
||||
* @return A Mono that completes when all clients have been notified
|
||||
*/
|
||||
public Mono<Void> notifyPromptsListChanged() {
|
||||
return this.delegate.notifyPromptsListChanged();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Management
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* This implementation would, incorrectly, broadcast the logging message to all
|
||||
* connected clients, using a single minLoggingLevel for all of them. Similar to the
|
||||
* sampling and roots, the logging level should be set per client session and use the
|
||||
* ServerExchange to send the logging message to the right client.
|
||||
*
|
||||
* @param loggingMessageNotification The logging message to send
|
||||
* @return A Mono that completes when the notification has been sent
|
||||
* @deprecated Use
|
||||
* {@link McpAsyncServerExchange#loggingNotification(LoggingMessageNotification)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
|
||||
return this.delegate.loggingNotification(loggingMessageNotification);
|
||||
}
|
||||
|
||||
public McpServerSession.Factory getSessionFactory() {
|
||||
return this.delegate.getSessionFactory();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Sampling
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* This method is package-private and used for test only. Should not be called by user
|
||||
* code.
|
||||
*
|
||||
* @param protocolVersions the Client supported protocol versions.
|
||||
*/
|
||||
void setProtocolVersions(List<String> protocolVersions) {
|
||||
this.delegate.setProtocolVersions(protocolVersions);
|
||||
}
|
||||
|
||||
private static class AsyncServerImpl extends McpAsyncServer {
|
||||
|
||||
private final McpServerTransportProvider mcpTransportProvider;
|
||||
|
||||
private McpServerSession.Factory sessionFactory;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private McpSchema.ServerCapabilities serverCapabilities;
|
||||
|
||||
private McpSchema.Implementation serverInfo;
|
||||
|
||||
private String instructions;
|
||||
|
||||
private final CopyOnWriteArrayList<McpServerFeatures.AsyncToolSpecification> tools = new CopyOnWriteArrayList<>();
|
||||
|
||||
private final CopyOnWriteArrayList<McpSchema.ResourceTemplate> resourceTemplates = new CopyOnWriteArrayList<>();
|
||||
|
||||
private final ConcurrentHashMap<String, McpServerFeatures.AsyncResourceSpecification> resources = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentHashMap<String, McpServerFeatures.AsyncPromptSpecification> prompts = new ConcurrentHashMap<>();
|
||||
|
||||
// FIXME: this field is deprecated and should be remvoed together with the
|
||||
// broadcasting loggingNotification.
|
||||
private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
|
||||
|
||||
private List<String> protocolVersions = List.of(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
AsyncServerImpl(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
|
||||
McpServerFeatures.Async features, boolean isProxy) {
|
||||
this.mcpTransportProvider = mcpTransportProvider;
|
||||
this.objectMapper = objectMapper;
|
||||
this.serverInfo = features.serverInfo();
|
||||
this.serverCapabilities = features.serverCapabilities();
|
||||
this.instructions = features.instructions();
|
||||
this.tools.addAll(features.tools());
|
||||
this.resources.putAll(features.resources());
|
||||
this.resourceTemplates.addAll(features.resourceTemplates());
|
||||
this.prompts.putAll(features.prompts());
|
||||
|
||||
Map<String, McpServerSession.RequestHandler<?>> requestHandlers = new HashMap<>();
|
||||
|
||||
// Initialize request handlers for standard MCP methods
|
||||
|
||||
// Ping MUST respond with an empty data, but not NULL response.
|
||||
requestHandlers.put(McpSchema.METHOD_PING, (exchange, params) -> {
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().ping();
|
||||
}
|
||||
return Mono.just(Map.of());
|
||||
});
|
||||
|
||||
// Add tools API handlers if the tool capability is enabled
|
||||
if (this.serverCapabilities.tools() != null || isProxy) {
|
||||
requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler());
|
||||
}
|
||||
|
||||
// Add resources API handlers if provided
|
||||
if (this.serverCapabilities.resources() != null || isProxy) {
|
||||
requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler());
|
||||
}
|
||||
|
||||
// Add prompts API handlers if provider exists
|
||||
if (this.serverCapabilities.prompts() != null || isProxy) {
|
||||
requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler());
|
||||
requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler());
|
||||
}
|
||||
|
||||
// Add logging API handlers if the logging capability is enabled
|
||||
if (this.serverCapabilities.logging() != null || isProxy) {
|
||||
requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
|
||||
}
|
||||
|
||||
Map<String, McpServerSession.NotificationHandler> notificationHandlers = new HashMap<>();
|
||||
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (exchange, params) -> Mono.empty());
|
||||
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers = features
|
||||
.rootsChangeConsumers();
|
||||
|
||||
if (Utils.isEmpty(rootsChangeConsumers)) {
|
||||
rootsChangeConsumers = List.of((exchange,
|
||||
roots) -> Mono.fromRunnable(() -> logger.warn(
|
||||
"Roots list changed notification, but no consumers provided. Roots list changed: {}",
|
||||
roots)));
|
||||
}
|
||||
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,
|
||||
asyncRootsListChangedNotificationHandler(rootsChangeConsumers));
|
||||
sessionFactory = transport -> new McpServerSession(UUID.randomUUID().toString(), transport,
|
||||
this::asyncInitializeRequestHandler, Mono::empty, requestHandlers, notificationHandlers);
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Lifecycle Management
|
||||
// ---------------------------------------
|
||||
private Mono<McpSchema.InitializeResult> asyncInitializeRequestHandler(
|
||||
McpSchema.InitializeRequest initializeRequest) {
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapperMono != null) {
|
||||
return Mono.create(sink ->
|
||||
mcpAsyncClientWrapperMono.doOnSuccess(mcpProxyAsyncClientWrapper -> {
|
||||
this.mcpAsyncClientWrapper = mcpProxyAsyncClientWrapper;
|
||||
this.serverCapabilities = mcpProxyAsyncClientWrapper.getClient().getServerCapabilities();
|
||||
this.serverInfo = mcpProxyAsyncClientWrapper.getClient().getServerInfo();
|
||||
McpSchema.InitializeResult initializeResult0 = new McpSchema.InitializeResult(LATEST_PROTOCOL_VERSION, serverCapabilities, serverInfo, instructions);
|
||||
sink.success(initializeResult0);
|
||||
}).doOnError(sink::error).subscribe());
|
||||
}
|
||||
return Mono.defer(() -> {
|
||||
logger.info("Client initialize request - Protocol: {}, Capabilities: {}, Info: {}",
|
||||
initializeRequest.protocolVersion(), initializeRequest.capabilities(),
|
||||
initializeRequest.clientInfo());
|
||||
|
||||
// The server MUST respond with the highest protocol version it supports
|
||||
// if
|
||||
// it does not support the requested (e.g. Client) version.
|
||||
String serverProtocolVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
|
||||
|
||||
if (this.protocolVersions.contains(initializeRequest.protocolVersion())) {
|
||||
// If the server supports the requested protocol version, it MUST
|
||||
// respond
|
||||
// with the same version.
|
||||
serverProtocolVersion = initializeRequest.protocolVersion();
|
||||
} else {
|
||||
logger.warn(
|
||||
"Client requested unsupported protocol version: {}, so the server will sugggest the {} version instead",
|
||||
initializeRequest.protocolVersion(), serverProtocolVersion);
|
||||
}
|
||||
|
||||
return Mono.just(new McpSchema.InitializeResult(serverProtocolVersion, this.serverCapabilities,
|
||||
this.serverInfo, this.instructions));
|
||||
});
|
||||
}
|
||||
|
||||
public McpSchema.ServerCapabilities getServerCapabilities() {
|
||||
return this.serverCapabilities;
|
||||
}
|
||||
|
||||
public McpSchema.Implementation getServerInfo() {
|
||||
return this.serverInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
if (this.mcpAsyncClientWrapper != null) {
|
||||
return this.mcpAsyncClientWrapper.closeGracefully();
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (this.mcpAsyncClientWrapper != null) {
|
||||
this.mcpAsyncClientWrapper.close();
|
||||
}
|
||||
}
|
||||
|
||||
private McpServerSession.NotificationHandler asyncRootsListChangedNotificationHandler(
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
|
||||
return (exchange, params) -> exchange.listRoots()
|
||||
.flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
|
||||
.flatMap(consumer -> consumer.apply(exchange, listRootsResult.roots()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling roots list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tool Management
|
||||
// ---------------------------------------
|
||||
|
||||
@Override
|
||||
public Mono<Void> addTool(McpServerFeatures.AsyncToolSpecification toolSpecification) {
|
||||
if (toolSpecification == null) {
|
||||
return Mono.error(new McpError("Tool specification must not be null"));
|
||||
}
|
||||
if (toolSpecification.tool() == null) {
|
||||
return Mono.error(new McpError("Tool must not be null"));
|
||||
}
|
||||
if (toolSpecification.call() == null) {
|
||||
return Mono.error(new McpError("Tool call handler must not be null"));
|
||||
}
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server must be configured with tool capabilities"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
// Check for duplicate tool names
|
||||
if (this.tools.stream().anyMatch(th -> th.tool().name().equals(toolSpecification.tool().name()))) {
|
||||
return Mono
|
||||
.error(new McpError("Tool with name '" + toolSpecification.tool().name() + "' already exists"));
|
||||
}
|
||||
|
||||
this.tools.add(toolSpecification);
|
||||
logger.debug("Added tool handler: {}", toolSpecification.tool().name());
|
||||
|
||||
if (this.serverCapabilities.tools().listChanged()) {
|
||||
return notifyToolsListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> removeTool(String toolName) {
|
||||
if (toolName == null) {
|
||||
return Mono.error(new McpError("Tool name must not be null"));
|
||||
}
|
||||
if (this.serverCapabilities.tools() == null) {
|
||||
return Mono.error(new McpError("Server must be configured with tool capabilities"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
boolean removed = this.tools
|
||||
.removeIf(toolSpecification -> toolSpecification.tool().name().equals(toolName));
|
||||
if (removed) {
|
||||
logger.debug("Removed tool handler: {}", toolName);
|
||||
if (this.serverCapabilities.tools().listChanged()) {
|
||||
return notifyToolsListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.error(new McpError("Tool with name '" + toolName + "' not found"));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> notifyToolsListChanged() {
|
||||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().listTools().timeout(Duration.ofSeconds(20)).onErrorResume(error -> Mono.error(new McpError("MCP tools/list timeout")));
|
||||
}
|
||||
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
|
||||
|
||||
return Mono.just(new McpSchema.ListToolsResult(tools, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<CallToolResult> toolsCallRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
|
||||
new TypeReference<McpSchema.CallToolRequest>() {
|
||||
});
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().callTool(callToolRequest);
|
||||
}
|
||||
|
||||
Optional<McpServerFeatures.AsyncToolSpecification> toolSpecification = this.tools.stream()
|
||||
.filter(tr -> callToolRequest.name().equals(tr.tool().name()))
|
||||
.findAny();
|
||||
|
||||
if (toolSpecification.isEmpty()) {
|
||||
return Mono.error(new McpError("Tool not found: " + callToolRequest.name()));
|
||||
}
|
||||
|
||||
return toolSpecification.map(tool -> tool.call().apply(exchange, callToolRequest.arguments()))
|
||||
.orElse(Mono.error(new McpError("Tool not found: " + callToolRequest.name())));
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resource Management
|
||||
// ---------------------------------------
|
||||
|
||||
@Override
|
||||
public Mono<Void> addResource(McpServerFeatures.AsyncResourceSpecification resourceSpecification) {
|
||||
if (resourceSpecification == null || resourceSpecification.resource() == null) {
|
||||
return Mono.error(new McpError("Resource must not be null"));
|
||||
}
|
||||
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server must be configured with resource capabilities"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
if (this.resources.putIfAbsent(resourceSpecification.resource().uri(), resourceSpecification) != null) {
|
||||
return Mono.error(new McpError(
|
||||
"Resource with URI '" + resourceSpecification.resource().uri() + "' already exists"));
|
||||
}
|
||||
logger.debug("Added resource handler: {}", resourceSpecification.resource().uri());
|
||||
if (this.serverCapabilities.resources().listChanged()) {
|
||||
return notifyResourcesListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> removeResource(String resourceUri) {
|
||||
if (resourceUri == null) {
|
||||
return Mono.error(new McpError("Resource URI must not be null"));
|
||||
}
|
||||
if (this.serverCapabilities.resources() == null) {
|
||||
return Mono.error(new McpError("Server must be configured with resource capabilities"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
McpServerFeatures.AsyncResourceSpecification removed = this.resources.remove(resourceUri);
|
||||
if (removed != null) {
|
||||
logger.debug("Removed resource handler: {}", resourceUri);
|
||||
if (this.serverCapabilities.resources().listChanged()) {
|
||||
return notifyResourcesListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found"));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> notifyResourcesListChanged() {
|
||||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().listResources();
|
||||
}
|
||||
|
||||
var resourceList = this.resources.values()
|
||||
.stream()
|
||||
.map(McpServerFeatures.AsyncResourceSpecification::resource)
|
||||
.toList();
|
||||
return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().listResourceTemplates();
|
||||
}
|
||||
return Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
|
||||
new TypeReference<McpSchema.ReadResourceRequest>() {
|
||||
});
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().readResource(resourceRequest);
|
||||
}
|
||||
|
||||
var resourceUri = resourceRequest.uri();
|
||||
McpServerFeatures.AsyncResourceSpecification specification = this.resources.get(resourceUri);
|
||||
if (specification != null) {
|
||||
return specification.readHandler().apply(exchange, resourceRequest);
|
||||
}
|
||||
return Mono.error(new McpError("Resource not found: " + resourceUri));
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Prompt Management
|
||||
// ---------------------------------------
|
||||
|
||||
@Override
|
||||
public Mono<Void> addPrompt(McpServerFeatures.AsyncPromptSpecification promptSpecification) {
|
||||
if (promptSpecification == null) {
|
||||
return Mono.error(new McpError("Prompt specification must not be null"));
|
||||
}
|
||||
if (this.serverCapabilities.prompts() == null) {
|
||||
return Mono.error(new McpError("Server must be configured with prompt capabilities"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
McpServerFeatures.AsyncPromptSpecification specification = this.prompts
|
||||
.putIfAbsent(promptSpecification.prompt().name(), promptSpecification);
|
||||
if (specification != null) {
|
||||
return Mono.error(new McpError(
|
||||
"Prompt with name '" + promptSpecification.prompt().name() + "' already exists"));
|
||||
}
|
||||
|
||||
logger.debug("Added prompt handler: {}", promptSpecification.prompt().name());
|
||||
|
||||
// Servers that declared the listChanged capability SHOULD send a
|
||||
// notification,
|
||||
// when the list of available prompts changes
|
||||
if (this.serverCapabilities.prompts().listChanged()) {
|
||||
return notifyPromptsListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> removePrompt(String promptName) {
|
||||
if (promptName == null) {
|
||||
return Mono.error(new McpError("Prompt name must not be null"));
|
||||
}
|
||||
if (this.serverCapabilities.prompts() == null) {
|
||||
return Mono.error(new McpError("Server must be configured with prompt capabilities"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
McpServerFeatures.AsyncPromptSpecification removed = this.prompts.remove(promptName);
|
||||
|
||||
if (removed != null) {
|
||||
logger.debug("Removed prompt handler: {}", promptName);
|
||||
// Servers that declared the listChanged capability SHOULD send a
|
||||
// notification, when the list of available prompts changes
|
||||
if (this.serverCapabilities.prompts().listChanged()) {
|
||||
return this.notifyPromptsListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.error(new McpError("Prompt with name '" + promptName + "' not found"));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> notifyPromptsListChanged() {
|
||||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
// TODO: Implement pagination
|
||||
// McpSchema.PaginatedRequest request = objectMapper.convertValue(params,
|
||||
// new TypeReference<McpSchema.PaginatedRequest>() {
|
||||
// });
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().listPrompts();
|
||||
}
|
||||
|
||||
var promptList = this.prompts.values()
|
||||
.stream()
|
||||
.map(McpServerFeatures.AsyncPromptSpecification::prompt)
|
||||
.toList();
|
||||
|
||||
return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params,
|
||||
new TypeReference<McpSchema.GetPromptRequest>() {
|
||||
});
|
||||
|
||||
|
||||
//代理执行
|
||||
if (mcpAsyncClientWrapper != null) {
|
||||
return mcpAsyncClientWrapper.getClient().getPrompt(promptRequest);
|
||||
}
|
||||
|
||||
// Implement prompt retrieval logic here
|
||||
McpServerFeatures.AsyncPromptSpecification specification = this.prompts.get(promptRequest.name());
|
||||
if (specification == null) {
|
||||
return Mono.error(new McpError("Prompt not found: " + promptRequest.name()));
|
||||
}
|
||||
|
||||
return specification.promptHandler().apply(exchange, promptRequest);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Logging Management
|
||||
// ---------------------------------------
|
||||
|
||||
@Override
|
||||
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
|
||||
|
||||
if (loggingMessageNotification == null) {
|
||||
return Mono.error(new McpError("Logging message must not be null"));
|
||||
}
|
||||
|
||||
if (loggingMessageNotification.level().level() < minLoggingLevel.level()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_MESSAGE,
|
||||
loggingMessageNotification);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<Object> setLoggerRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
return Mono.defer(() -> {
|
||||
|
||||
SetLevelRequest newMinLoggingLevel = objectMapper.convertValue(params,
|
||||
new TypeReference<SetLevelRequest>() {
|
||||
});
|
||||
|
||||
exchange.setMinLoggingLevel(newMinLoggingLevel.level());
|
||||
|
||||
// FIXME: this field is deprecated and should be removed together
|
||||
// with the broadcasting loggingNotification.
|
||||
this.minLoggingLevel = newMinLoggingLevel.level();
|
||||
|
||||
return Mono.just(Map.of());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Sampling
|
||||
// ---------------------------------------
|
||||
|
||||
@Override
|
||||
void setProtocolVersions(List<String> protocolVersions) {
|
||||
this.protocolVersions = protocolVersions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public McpServerSession.Factory getSessionFactory() {
|
||||
return sessionFactory;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Represents an asynchronous exchange with a Model Context Protocol (MCP) client. The
|
||||
* exchange provides methods to interact with the client and query its capabilities.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
public class McpAsyncServerExchange {
|
||||
|
||||
private final McpServerSession session;
|
||||
|
||||
private final McpSchema.ClientCapabilities clientCapabilities;
|
||||
|
||||
private final McpSchema.Implementation clientInfo;
|
||||
|
||||
private volatile LoggingLevel minLoggingLevel = LoggingLevel.INFO;
|
||||
|
||||
private static final TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private static final TypeReference<McpSchema.ListRootsResult> LIST_ROOTS_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new asynchronous exchange with the client.
|
||||
*
|
||||
* @param session The server session representing a 1-1 interaction.
|
||||
* @param clientCapabilities The client capabilities that define the supported
|
||||
* features and functionality.
|
||||
* @param clientInfo The client implementation information.
|
||||
*/
|
||||
public McpAsyncServerExchange(McpServerSession session, McpSchema.ClientCapabilities clientCapabilities,
|
||||
McpSchema.Implementation clientInfo) {
|
||||
this.session = session;
|
||||
this.clientCapabilities = clientCapabilities;
|
||||
this.clientInfo = clientInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client capabilities that define the supported features and functionality.
|
||||
*
|
||||
* @return The client capabilities
|
||||
*/
|
||||
public McpSchema.ClientCapabilities getClientCapabilities() {
|
||||
return this.clientCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client implementation information.
|
||||
*
|
||||
* @return The client implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getClientInfo() {
|
||||
return this.clientInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new message using the sampling capabilities of the client. The Model
|
||||
* Context Protocol (MCP) provides a standardized way for servers to request LLM
|
||||
* sampling (“completions” or “generations”) from language models via clients. This
|
||||
* flow allows clients to maintain control over model access, selection, and
|
||||
* permissions while enabling servers to leverage AI capabilities—with no server API
|
||||
* keys necessary. Servers can request text or image-based interactions and optionally
|
||||
* include context from MCP servers in their prompts.
|
||||
*
|
||||
* @param createMessageRequest The request to create a new message
|
||||
* @return A Mono that completes when the message has been created
|
||||
* @see McpSchema.CreateMessageRequest
|
||||
* @see McpSchema.CreateMessageResult
|
||||
* @see <a href=
|
||||
* "https://spec.modelcontextprotocol.io/specification/client/sampling/">Sampling
|
||||
* Specification</a>
|
||||
*/
|
||||
public Mono<McpSchema.CreateMessageResult> createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
|
||||
if (this.clientCapabilities == null) {
|
||||
return Mono.error(new McpError("Client must be initialized. Call the initialize method first!"));
|
||||
}
|
||||
if (this.clientCapabilities.sampling() == null) {
|
||||
return Mono.error(new McpError("Client must be configured with sampling capabilities"));
|
||||
}
|
||||
return this.session.sendRequest(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, createMessageRequest,
|
||||
CREATE_MESSAGE_RESULT_TYPE_REF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all roots provided by the client.
|
||||
*
|
||||
* @return A Mono that emits the list of roots result.
|
||||
*/
|
||||
public Mono<McpSchema.ListRootsResult> listRoots() {
|
||||
return this.listRoots(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of roots provided by the client.
|
||||
*
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return A Mono that emits the list of roots result containing
|
||||
*/
|
||||
public Mono<McpSchema.ListRootsResult> listRoots(String cursor) {
|
||||
return this.session.sendRequest(McpSchema.METHOD_ROOTS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_ROOTS_RESULT_TYPE_REF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a logging message notification to all connected clients. Messages below the
|
||||
* current minimum logging level will be filtered out.
|
||||
*
|
||||
* @param loggingMessageNotification The logging message to send
|
||||
* @return A Mono that completes when the notification has been sent
|
||||
*/
|
||||
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
|
||||
|
||||
if (loggingMessageNotification == null) {
|
||||
return Mono.error(new McpError("Logging message must not be null"));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
if (this.isNotificationForLevelAllowed(loggingMessageNotification.level())) {
|
||||
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingMessageNotification);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the minimum logging level for the client. Messages below this level will be
|
||||
* filtered out.
|
||||
*
|
||||
* @param minLoggingLevel The minimum logging level
|
||||
*/
|
||||
void setMinLoggingLevel(LoggingLevel minLoggingLevel) {
|
||||
Assert.notNull(minLoggingLevel, "minLoggingLevel must not be null");
|
||||
this.minLoggingLevel = minLoggingLevel;
|
||||
}
|
||||
|
||||
private boolean isNotificationForLevelAllowed(LoggingLevel loggingLevel) {
|
||||
return loggingLevel.level() >= this.minLoggingLevel.level();
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* MCP server features specification that a particular server can choose to support.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class McpServerFeatures {
|
||||
|
||||
/**
|
||||
* Asynchronous server features specification.
|
||||
*
|
||||
* @param serverInfo The server implementation details
|
||||
* @param serverCapabilities The server capabilities
|
||||
* @param tools The list of tool specifications
|
||||
* @param resources The map of resource specifications
|
||||
* @param resourceTemplates The list of resource templates
|
||||
* @param prompts The map of prompt specifications
|
||||
* @param rootsChangeConsumers The list of consumers that will be notified when the
|
||||
* roots list changes
|
||||
* @param instructions The server instructions text
|
||||
*/
|
||||
record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
|
||||
List<McpServerFeatures.AsyncToolSpecification> tools, Map<String, AsyncResourceSpecification> resources,
|
||||
List<McpSchema.ResourceTemplate> resourceTemplates,
|
||||
Map<String, McpServerFeatures.AsyncPromptSpecification> prompts,
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers,
|
||||
String instructions) {
|
||||
|
||||
/**
|
||||
* Create an instance and validate the arguments.
|
||||
* @param serverInfo The server implementation details
|
||||
* @param serverCapabilities The server capabilities
|
||||
* @param tools The list of tool specifications
|
||||
* @param resources The map of resource specifications
|
||||
* @param resourceTemplates The list of resource templates
|
||||
* @param prompts The map of prompt specifications
|
||||
* @param rootsChangeConsumers The list of consumers that will be notified when
|
||||
* the roots list changes
|
||||
* @param instructions The server instructions text
|
||||
*/
|
||||
Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
|
||||
List<McpServerFeatures.AsyncToolSpecification> tools, Map<String, AsyncResourceSpecification> resources,
|
||||
List<McpSchema.ResourceTemplate> resourceTemplates,
|
||||
Map<String, McpServerFeatures.AsyncPromptSpecification> prompts,
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers,
|
||||
String instructions) {
|
||||
|
||||
Assert.notNull(serverInfo, "Server info must not be null");
|
||||
|
||||
this.serverInfo = serverInfo;
|
||||
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities
|
||||
: new McpSchema.ServerCapabilities(null, // experimental
|
||||
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable
|
||||
// logging
|
||||
// by
|
||||
// default
|
||||
!Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
|
||||
!Utils.isEmpty(resources)
|
||||
? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null,
|
||||
!Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
|
||||
|
||||
this.tools = (tools != null) ? tools : List.of();
|
||||
this.resources = (resources != null) ? resources : Map.of();
|
||||
this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : List.of();
|
||||
this.prompts = (prompts != null) ? prompts : Map.of();
|
||||
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of();
|
||||
this.instructions = instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a synchronous specification into an asynchronous one and provide
|
||||
* blocking code offloading to prevent accidental blocking of the non-blocking
|
||||
* transport.
|
||||
* @param syncSpec a potentially blocking, synchronous specification.
|
||||
* @return a specification which is protected from blocking calls specified by the
|
||||
* user.
|
||||
*/
|
||||
static Async fromSync(Sync syncSpec) {
|
||||
List<McpServerFeatures.AsyncToolSpecification> tools = new ArrayList<>();
|
||||
for (var tool : syncSpec.tools()) {
|
||||
tools.add(AsyncToolSpecification.fromSync(tool));
|
||||
}
|
||||
|
||||
Map<String, AsyncResourceSpecification> resources = new HashMap<>();
|
||||
syncSpec.resources().forEach((key, resource) -> {
|
||||
resources.put(key, AsyncResourceSpecification.fromSync(resource));
|
||||
});
|
||||
|
||||
Map<String, AsyncPromptSpecification> prompts = new HashMap<>();
|
||||
syncSpec.prompts().forEach((key, prompt) -> {
|
||||
prompts.put(key, AsyncPromptSpecification.fromSync(prompt));
|
||||
});
|
||||
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootChangeConsumers = new ArrayList<>();
|
||||
|
||||
for (var rootChangeConsumer : syncSpec.rootsChangeConsumers()) {
|
||||
rootChangeConsumers.add((exchange, list) -> Mono
|
||||
.<Void>fromRunnable(() -> rootChangeConsumer.accept(new McpSyncServerExchange(exchange), list))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources,
|
||||
syncSpec.resourceTemplates(), prompts, rootChangeConsumers, syncSpec.instructions());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous server features specification.
|
||||
*
|
||||
* @param serverInfo The server implementation details
|
||||
* @param serverCapabilities The server capabilities
|
||||
* @param tools The list of tool specifications
|
||||
* @param resources The map of resource specifications
|
||||
* @param resourceTemplates The list of resource templates
|
||||
* @param prompts The map of prompt specifications
|
||||
* @param rootsChangeConsumers The list of consumers that will be notified when the
|
||||
* roots list changes
|
||||
* @param instructions The server instructions text
|
||||
*/
|
||||
record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
|
||||
List<McpServerFeatures.SyncToolSpecification> tools,
|
||||
Map<String, McpServerFeatures.SyncResourceSpecification> resources,
|
||||
List<McpSchema.ResourceTemplate> resourceTemplates,
|
||||
Map<String, McpServerFeatures.SyncPromptSpecification> prompts,
|
||||
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers, String instructions) {
|
||||
|
||||
/**
|
||||
* Create an instance and validate the arguments.
|
||||
* @param serverInfo The server implementation details
|
||||
* @param serverCapabilities The server capabilities
|
||||
* @param tools The list of tool specifications
|
||||
* @param resources The map of resource specifications
|
||||
* @param resourceTemplates The list of resource templates
|
||||
* @param prompts The map of prompt specifications
|
||||
* @param rootsChangeConsumers The list of consumers that will be notified when
|
||||
* the roots list changes
|
||||
* @param instructions The server instructions text
|
||||
*/
|
||||
Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities,
|
||||
List<McpServerFeatures.SyncToolSpecification> tools,
|
||||
Map<String, McpServerFeatures.SyncResourceSpecification> resources,
|
||||
List<McpSchema.ResourceTemplate> resourceTemplates,
|
||||
Map<String, McpServerFeatures.SyncPromptSpecification> prompts,
|
||||
List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> rootsChangeConsumers,
|
||||
String instructions) {
|
||||
|
||||
Assert.notNull(serverInfo, "Server info must not be null");
|
||||
|
||||
this.serverInfo = serverInfo;
|
||||
this.serverCapabilities = (serverCapabilities != null) ? serverCapabilities
|
||||
: new McpSchema.ServerCapabilities(null, // experimental
|
||||
new McpSchema.ServerCapabilities.LoggingCapabilities(), // Enable
|
||||
// logging
|
||||
// by
|
||||
// default
|
||||
!Utils.isEmpty(prompts) ? new McpSchema.ServerCapabilities.PromptCapabilities(false) : null,
|
||||
!Utils.isEmpty(resources)
|
||||
? new McpSchema.ServerCapabilities.ResourceCapabilities(false, false) : null,
|
||||
!Utils.isEmpty(tools) ? new McpSchema.ServerCapabilities.ToolCapabilities(false) : null);
|
||||
|
||||
this.tools = (tools != null) ? tools : new ArrayList<>();
|
||||
this.resources = (resources != null) ? resources : new HashMap<>();
|
||||
this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : new ArrayList<>();
|
||||
this.prompts = (prompts != null) ? prompts : new HashMap<>();
|
||||
this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>();
|
||||
this.instructions = instructions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification of a tool with its asynchronous handler function. Tools are the
|
||||
* primary way for MCP servers to expose functionality to AI models. Each tool
|
||||
* represents a specific capability, such as:
|
||||
* <ul>
|
||||
* <li>Performing calculations
|
||||
* <li>Accessing external APIs
|
||||
* <li>Querying databases
|
||||
* <li>Manipulating files
|
||||
* <li>Executing system commands
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example tool specification: <pre>{@code
|
||||
* new McpServerFeatures.AsyncToolSpecification(
|
||||
* new Tool(
|
||||
* "calculator",
|
||||
* "Performs mathematical calculations",
|
||||
* new JsonSchemaObject()
|
||||
* .required("expression")
|
||||
* .property("expression", JsonSchemaType.STRING)
|
||||
* ),
|
||||
* (exchange, args) -> {
|
||||
* String expr = (String) args.get("expression");
|
||||
* return Mono.fromSupplier(() -> evaluate(expr))
|
||||
* .map(result -> new CallToolResult("Result: " + result));
|
||||
* }
|
||||
* )
|
||||
* }</pre>
|
||||
*
|
||||
* @param tool The tool definition including name, description, and parameter schema
|
||||
* @param call The function that implements the tool's logic, receiving arguments and
|
||||
* returning results. The function's first argument is an
|
||||
* {@link McpAsyncServerExchange} upon which the server can interact with the
|
||||
* connected client. The second arguments is a map of tool arguments.
|
||||
*/
|
||||
public record AsyncToolSpecification(McpSchema.Tool tool,
|
||||
BiFunction<McpAsyncServerExchange, Map<String, Object>, Mono<McpSchema.CallToolResult>> call) {
|
||||
|
||||
static AsyncToolSpecification fromSync(SyncToolSpecification tool) {
|
||||
// FIXME: This is temporary, proper validation should be implemented
|
||||
if (tool == null) {
|
||||
return null;
|
||||
}
|
||||
return new AsyncToolSpecification(tool.tool(),
|
||||
(exchange, map) -> Mono
|
||||
.fromCallable(() -> tool.call().apply(new McpSyncServerExchange(exchange), map))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification of a resource with its asynchronous handler function. Resources
|
||||
* provide context to AI models by exposing data such as:
|
||||
* <ul>
|
||||
* <li>File contents
|
||||
* <li>Database records
|
||||
* <li>API responses
|
||||
* <li>System information
|
||||
* <li>Application state
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example resource specification: <pre>{@code
|
||||
* new McpServerFeatures.AsyncResourceSpecification(
|
||||
* new Resource("docs", "Documentation files", "text/markdown"),
|
||||
* (exchange, request) ->
|
||||
* Mono.fromSupplier(() -> readFile(request.getPath()))
|
||||
* .map(ReadResourceResult::new)
|
||||
* )
|
||||
* }</pre>
|
||||
*
|
||||
* @param resource The resource definition including name, description, and MIME type
|
||||
* @param readHandler The function that handles resource read requests. The function's
|
||||
* first argument is an {@link McpAsyncServerExchange} upon which the server can
|
||||
* interact with the connected client. The second arguments is a
|
||||
* {@link io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest}.
|
||||
*/
|
||||
public record AsyncResourceSpecification(McpSchema.Resource resource,
|
||||
BiFunction<McpAsyncServerExchange, McpSchema.ReadResourceRequest, Mono<McpSchema.ReadResourceResult>> readHandler) {
|
||||
|
||||
static AsyncResourceSpecification fromSync(SyncResourceSpecification resource) {
|
||||
// FIXME: This is temporary, proper validation should be implemented
|
||||
if (resource == null) {
|
||||
return null;
|
||||
}
|
||||
return new AsyncResourceSpecification(resource.resource(),
|
||||
(exchange, req) -> Mono
|
||||
.fromCallable(() -> resource.readHandler().apply(new McpSyncServerExchange(exchange), req))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification of a prompt template with its asynchronous handler function. Prompts
|
||||
* provide structured templates for AI model interactions, supporting:
|
||||
* <ul>
|
||||
* <li>Consistent message formatting
|
||||
* <li>Parameter substitution
|
||||
* <li>Context injection
|
||||
* <li>Response formatting
|
||||
* <li>Instruction templating
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example prompt specification: <pre>{@code
|
||||
* new McpServerFeatures.AsyncPromptSpecification(
|
||||
* new Prompt("analyze", "Code analysis template"),
|
||||
* (exchange, request) -> {
|
||||
* String code = request.getArguments().get("code");
|
||||
* return Mono.just(new GetPromptResult(
|
||||
* "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
|
||||
* ));
|
||||
* }
|
||||
* )
|
||||
* }</pre>
|
||||
*
|
||||
* @param prompt The prompt definition including name and description
|
||||
* @param promptHandler The function that processes prompt requests and returns
|
||||
* formatted templates. The function's first argument is an
|
||||
* {@link McpAsyncServerExchange} upon which the server can interact with the
|
||||
* connected client. The second arguments is a
|
||||
* {@link io.modelcontextprotocol.spec.McpSchema.GetPromptRequest}.
|
||||
*/
|
||||
public record AsyncPromptSpecification(McpSchema.Prompt prompt,
|
||||
BiFunction<McpAsyncServerExchange, McpSchema.GetPromptRequest, Mono<McpSchema.GetPromptResult>> promptHandler) {
|
||||
|
||||
static AsyncPromptSpecification fromSync(SyncPromptSpecification prompt) {
|
||||
// FIXME: This is temporary, proper validation should be implemented
|
||||
if (prompt == null) {
|
||||
return null;
|
||||
}
|
||||
return new AsyncPromptSpecification(prompt.prompt(),
|
||||
(exchange, req) -> Mono
|
||||
.fromCallable(() -> prompt.promptHandler().apply(new McpSyncServerExchange(exchange), req))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification of a tool with its synchronous handler function. Tools are the
|
||||
* primary way for MCP servers to expose functionality to AI models. Each tool
|
||||
* represents a specific capability, such as:
|
||||
* <ul>
|
||||
* <li>Performing calculations
|
||||
* <li>Accessing external APIs
|
||||
* <li>Querying databases
|
||||
* <li>Manipulating files
|
||||
* <li>Executing system commands
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example tool specification: <pre>{@code
|
||||
* new McpServerFeatures.SyncToolSpecification(
|
||||
* new Tool(
|
||||
* "calculator",
|
||||
* "Performs mathematical calculations",
|
||||
* new JsonSchemaObject()
|
||||
* .required("expression")
|
||||
* .property("expression", JsonSchemaType.STRING)
|
||||
* ),
|
||||
* (exchange, args) -> {
|
||||
* String expr = (String) args.get("expression");
|
||||
* return new CallToolResult("Result: " + evaluate(expr));
|
||||
* }
|
||||
* )
|
||||
* }</pre>
|
||||
*
|
||||
* @param tool The tool definition including name, description, and parameter schema
|
||||
* @param call The function that implements the tool's logic, receiving arguments and
|
||||
* returning results. The function's first argument is an
|
||||
* {@link McpSyncServerExchange} upon which the server can interact with the connected
|
||||
* client. The second arguments is a map of arguments passed to the tool.
|
||||
*/
|
||||
public record SyncToolSpecification(McpSchema.Tool tool,
|
||||
BiFunction<McpSyncServerExchange, Map<String, Object>, McpSchema.CallToolResult> call) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification of a resource with its synchronous handler function. Resources
|
||||
* provide context to AI models by exposing data such as:
|
||||
* <ul>
|
||||
* <li>File contents
|
||||
* <li>Database records
|
||||
* <li>API responses
|
||||
* <li>System information
|
||||
* <li>Application state
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example resource specification: <pre>{@code
|
||||
* new McpServerFeatures.SyncResourceSpecification(
|
||||
* new Resource("docs", "Documentation files", "text/markdown"),
|
||||
* (exchange, request) -> {
|
||||
* String content = readFile(request.getPath());
|
||||
* return new ReadResourceResult(content);
|
||||
* }
|
||||
* )
|
||||
* }</pre>
|
||||
*
|
||||
* @param resource The resource definition including name, description, and MIME type
|
||||
* @param readHandler The function that handles resource read requests. The function's
|
||||
* first argument is an {@link McpSyncServerExchange} upon which the server can
|
||||
* interact with the connected client. The second arguments is a
|
||||
* {@link io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest}.
|
||||
*/
|
||||
public record SyncResourceSpecification(McpSchema.Resource resource,
|
||||
BiFunction<McpSyncServerExchange, McpSchema.ReadResourceRequest, McpSchema.ReadResourceResult> readHandler) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification of a prompt template with its synchronous handler function. Prompts
|
||||
* provide structured templates for AI model interactions, supporting:
|
||||
* <ul>
|
||||
* <li>Consistent message formatting
|
||||
* <li>Parameter substitution
|
||||
* <li>Context injection
|
||||
* <li>Response formatting
|
||||
* <li>Instruction templating
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* Example prompt specification: <pre>{@code
|
||||
* new McpServerFeatures.SyncPromptSpecification(
|
||||
* new Prompt("analyze", "Code analysis template"),
|
||||
* (exchange, request) -> {
|
||||
* String code = request.getArguments().get("code");
|
||||
* return new GetPromptResult(
|
||||
* "Analyze this code:\n\n" + code + "\n\nProvide feedback on:"
|
||||
* );
|
||||
* }
|
||||
* )
|
||||
* }</pre>
|
||||
*
|
||||
* @param prompt The prompt definition including name and description
|
||||
* @param promptHandler The function that processes prompt requests and returns
|
||||
* formatted templates. The function's first argument is an
|
||||
* {@link McpSyncServerExchange} upon which the server can interact with the connected
|
||||
* client. The second arguments is a
|
||||
* {@link io.modelcontextprotocol.spec.McpSchema.GetPromptRequest}.
|
||||
*/
|
||||
public record SyncPromptSpecification(McpSchema.Prompt prompt,
|
||||
BiFunction<McpSyncServerExchange, McpSchema.GetPromptRequest, McpSchema.GetPromptResult> promptHandler) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.xspaceagi.system.spec.utils.TimeWheel;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpSession;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.MonoSink;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Represents a Model Control Protocol (MCP) session on the server side. It manages
|
||||
* bidirectional JSON-RPC communication with the client.
|
||||
*/
|
||||
public class McpServerSession implements McpSession {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpServerSession.class);
|
||||
|
||||
private final ConcurrentHashMap<Object, MonoSink<McpSchema.JSONRPCResponse>> pendingResponses = new ConcurrentHashMap<>();
|
||||
|
||||
private final String id;
|
||||
|
||||
private final AtomicLong requestCounter = new AtomicLong(0);
|
||||
|
||||
private final InitRequestHandler initRequestHandler;
|
||||
|
||||
private final InitNotificationHandler initNotificationHandler;
|
||||
|
||||
private final Map<String, RequestHandler<?>> requestHandlers;
|
||||
|
||||
private final Map<String, NotificationHandler> notificationHandlers;
|
||||
|
||||
private final McpServerTransport transport;
|
||||
|
||||
private final Sinks.One<McpAsyncServerExchange> exchangeSink = Sinks.one();
|
||||
|
||||
private final AtomicReference<McpSchema.ClientCapabilities> clientCapabilities = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<McpSchema.Implementation> clientInfo = new AtomicReference<>();
|
||||
|
||||
private static final int STATE_UNINITIALIZED = 0;
|
||||
|
||||
private static final int STATE_INITIALIZING = 1;
|
||||
|
||||
private static final int STATE_INITIALIZED = 2;
|
||||
|
||||
private final AtomicInteger state = new AtomicInteger(STATE_UNINITIALIZED);
|
||||
|
||||
private McpAsyncServer mcpAsyncServer;
|
||||
|
||||
private Long lastActivityTime = System.currentTimeMillis();
|
||||
|
||||
/**
|
||||
* Creates a new server session with the given parameters and the transport to use.
|
||||
*
|
||||
* @param id session id
|
||||
* @param transport the transport to use
|
||||
* @param initHandler called when a
|
||||
* {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the
|
||||
* server
|
||||
* @param initNotificationHandler called when a
|
||||
* {@link McpSchema.METHOD_NOTIFICATION_INITIALIZED} is received.
|
||||
* @param requestHandlers map of request handlers to use
|
||||
* @param notificationHandlers map of notification handlers to use
|
||||
*/
|
||||
public McpServerSession(String id, McpServerTransport transport, InitRequestHandler initHandler,
|
||||
InitNotificationHandler initNotificationHandler, Map<String, RequestHandler<?>> requestHandlers,
|
||||
Map<String, NotificationHandler> notificationHandlers) {
|
||||
this.id = id;
|
||||
this.transport = transport;
|
||||
this.initRequestHandler = initHandler;
|
||||
this.initNotificationHandler = initNotificationHandler;
|
||||
this.requestHandlers = requestHandlers;
|
||||
this.notificationHandlers = notificationHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the session id.
|
||||
*
|
||||
* @return session id
|
||||
*/
|
||||
public String getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon successful initialization sequence between the client and the server
|
||||
* with the client capabilities and information.
|
||||
* <p>
|
||||
* <a href=
|
||||
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">Initialization
|
||||
* Spec</a>
|
||||
*
|
||||
* @param clientCapabilities the capabilities the connected client provides
|
||||
* @param clientInfo the information about the connected client
|
||||
*/
|
||||
public void init(McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo) {
|
||||
this.clientCapabilities.lazySet(clientCapabilities);
|
||||
this.clientInfo.lazySet(clientInfo);
|
||||
}
|
||||
|
||||
private String generateRequestId() {
|
||||
return this.id + "-" + this.requestCounter.getAndIncrement();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Mono<T> sendRequest(String method, Object requestParams, TypeReference<T> typeRef) {
|
||||
String requestId = this.generateRequestId();
|
||||
|
||||
return Mono.<McpSchema.JSONRPCResponse>create(sink -> {
|
||||
this.pendingResponses.put(requestId, sink);
|
||||
McpSchema.JSONRPCRequest jsonrpcRequest = new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, method,
|
||||
requestId, requestParams);
|
||||
this.transport.sendMessage(jsonrpcRequest).subscribe(v -> {
|
||||
}, error -> {
|
||||
this.pendingResponses.remove(requestId);
|
||||
sink.error(error);
|
||||
});
|
||||
}).timeout(Duration.ofSeconds(10)).handle((jsonRpcResponse, sink) -> {
|
||||
if (jsonRpcResponse.error() != null) {
|
||||
sink.error(new McpError(jsonRpcResponse.error()));
|
||||
} else {
|
||||
if (typeRef.getType().equals(Void.class)) {
|
||||
sink.complete();
|
||||
} else {
|
||||
sink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendNotification(String method, Object params) {
|
||||
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
|
||||
method, params);
|
||||
return this.transport.sendMessage(jsonrpcNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the {@link McpServerTransportProvider} once the session is determined.
|
||||
* The purpose of this method is to dispatch the message to an appropriate handler as
|
||||
* specified by the MCP server implementation
|
||||
* ({@link io.modelcontextprotocol.server.McpAsyncServer} or
|
||||
* {@link io.modelcontextprotocol.server.McpSyncServer}) via
|
||||
* {@link McpServerSession.Factory} that the server creates.
|
||||
*
|
||||
* @param message the incoming JSON-RPC message
|
||||
* @return a Mono that completes when the message is processed
|
||||
*/
|
||||
public Mono<Void> handle(McpSchema.JSONRPCMessage message) {
|
||||
lastActivityTime = System.currentTimeMillis();
|
||||
return Mono.defer(() -> {
|
||||
// TODO handle errors for communication to without initialization happening
|
||||
// first
|
||||
if (message instanceof McpSchema.JSONRPCResponse response) {
|
||||
logger.debug("Received Response: {}", response);
|
||||
var sink = pendingResponses.remove(response.id());
|
||||
if (sink == null) {
|
||||
logger.warn("Unexpected response for unknown id {}", response.id());
|
||||
} else {
|
||||
sink.success(response);
|
||||
}
|
||||
return Mono.empty();
|
||||
} else if (message instanceof McpSchema.JSONRPCRequest request) {
|
||||
logger.debug("Received request: {}", request);
|
||||
return handleIncomingRequest(request).onErrorResume(error -> {
|
||||
var errorResponse = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
|
||||
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
|
||||
error.getMessage(), null));
|
||||
// TODO: Should the error go to SSE or back as POST return?
|
||||
return this.transport.sendMessage(errorResponse).then(Mono.empty());
|
||||
}).flatMap(this.transport::sendMessage);
|
||||
} else if (message instanceof McpSchema.JSONRPCNotification notification) {
|
||||
// TODO handle errors for communication to without initialization
|
||||
// happening first
|
||||
logger.debug("Received notification: {}", notification);
|
||||
// TODO: in case of error, should the POST request be signalled?
|
||||
return handleIncomingNotification(notification)
|
||||
.doOnError(error -> logger.error("Error handling notification: {}", error.getMessage()));
|
||||
} else {
|
||||
logger.warn("Received unknown message type: {}", message);
|
||||
return Mono.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming JSON-RPC request by routing it to the appropriate handler.
|
||||
*
|
||||
* @param request The incoming JSON-RPC request
|
||||
* @return A Mono containing the JSON-RPC response
|
||||
*/
|
||||
private Mono<McpSchema.JSONRPCResponse> handleIncomingRequest(McpSchema.JSONRPCRequest request) {
|
||||
return Mono.defer(() -> {
|
||||
Mono<?> resultMono;
|
||||
if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
|
||||
// TODO handle situation where already initialized!
|
||||
McpSchema.InitializeRequest initializeRequest = transport.unmarshalFrom(request.params(),
|
||||
new TypeReference<McpSchema.InitializeRequest>() {
|
||||
});
|
||||
|
||||
this.state.lazySet(STATE_INITIALIZING);
|
||||
this.init(initializeRequest.capabilities(), initializeRequest.clientInfo());
|
||||
resultMono = this.initRequestHandler.handle(initializeRequest);
|
||||
} else {
|
||||
// TODO handle errors for communication to this session without
|
||||
// initialization happening first
|
||||
var handler = this.requestHandlers.get(request.method());
|
||||
if (handler == null) {
|
||||
logger.warn("Method not found: {}", request.method());
|
||||
MethodNotFoundError error = getMethodNotFoundError(request.method());
|
||||
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
|
||||
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.METHOD_NOT_FOUND,
|
||||
error.message(), error.data())));
|
||||
}
|
||||
|
||||
resultMono = this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, request.params()));
|
||||
}
|
||||
|
||||
return resultMono
|
||||
.map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
|
||||
.onErrorResume(error -> {
|
||||
logger.warn("Error handling request: {}", error.getMessage());
|
||||
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(),
|
||||
null, new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
|
||||
error.getMessage(), null)));
|
||||
}); // TODO: add error message
|
||||
// through the data field
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming JSON-RPC notification by routing it to the appropriate handler.
|
||||
*
|
||||
* @param notification The incoming JSON-RPC notification
|
||||
* @return A Mono that completes when the notification is processed
|
||||
*/
|
||||
private Mono<Void> handleIncomingNotification(McpSchema.JSONRPCNotification notification) {
|
||||
return Mono.defer(() -> {
|
||||
if (McpSchema.METHOD_NOTIFICATION_INITIALIZED.equals(notification.method())) {
|
||||
this.state.lazySet(STATE_INITIALIZED);
|
||||
exchangeSink.tryEmitValue(new McpAsyncServerExchange(this, clientCapabilities.get(), clientInfo.get()));
|
||||
return this.initNotificationHandler.handle();
|
||||
}
|
||||
|
||||
var handler = notificationHandlers.get(notification.method());
|
||||
if (handler == null) {
|
||||
logger.error("No handler registered for notification method: {}", notification.method());
|
||||
return Mono.empty();
|
||||
}
|
||||
return this.exchangeSink.asMono().flatMap(exchange -> handler.handle(exchange, notification.params()));
|
||||
});
|
||||
}
|
||||
|
||||
record MethodNotFoundError(String method, String message, Object data) {
|
||||
}
|
||||
|
||||
static MethodNotFoundError getMethodNotFoundError(String method) {
|
||||
switch (method) {
|
||||
case McpSchema.METHOD_ROOTS_LIST:
|
||||
return new MethodNotFoundError(method, "Roots not supported",
|
||||
Map.of("reason", "Client does not have roots capability"));
|
||||
default:
|
||||
return new MethodNotFoundError(method, "Method not found: " + method, null);
|
||||
}
|
||||
}
|
||||
|
||||
public void setMcpAsyncServer(McpAsyncServer mcpAsyncServer) {
|
||||
this.mcpAsyncServer = mcpAsyncServer;
|
||||
}
|
||||
|
||||
public McpAsyncServer getMcpAsyncServer() {
|
||||
return mcpAsyncServer;
|
||||
}
|
||||
|
||||
public void startObserving(TimeWheel timeWheel, Consumer<McpServerSession> timeoutCallback) {
|
||||
if (timeWheel != null) {
|
||||
timeWheel.schedule((res) -> {
|
||||
if (System.currentTimeMillis() - lastActivityTime > 120 * 1000) {
|
||||
timeoutCallback.accept(McpServerSession.this);
|
||||
} else {
|
||||
startObserving(timeWheel, timeoutCallback);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return this.transport.closeGracefully();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
this.transport.close();
|
||||
pendingResponses.forEach((id, sink) -> sink.error(new Exception("Session closed")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request handler for the initialization request.
|
||||
*/
|
||||
public interface InitRequestHandler {
|
||||
|
||||
/**
|
||||
* Handles the initialization request.
|
||||
*
|
||||
* @param initializeRequest the initialization request by the client
|
||||
* @return a Mono that will emit the result of the initialization
|
||||
*/
|
||||
Mono<McpSchema.InitializeResult> handle(McpSchema.InitializeRequest initializeRequest);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification handler for the initialization notification from the client.
|
||||
*/
|
||||
public interface InitNotificationHandler {
|
||||
|
||||
/**
|
||||
* Specifies an action to take upon successful initialization.
|
||||
*
|
||||
* @return a Mono that will complete when the initialization is acted upon.
|
||||
*/
|
||||
Mono<Void> handle();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler for client-initiated notifications.
|
||||
*/
|
||||
public interface NotificationHandler {
|
||||
|
||||
/**
|
||||
* Handles a notification from the client.
|
||||
*
|
||||
* @param exchange the exchange associated with the client that allows calling
|
||||
* back to the connected client or inspecting its capabilities.
|
||||
* @param params the parameters of the notification.
|
||||
* @return a Mono that completes once the notification is handled.
|
||||
*/
|
||||
Mono<Void> handle(McpAsyncServerExchange exchange, Object params);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler for client-initiated requests.
|
||||
*
|
||||
* @param <T> the type of the response that is expected as a result of handling the
|
||||
* request.
|
||||
*/
|
||||
public interface RequestHandler<T> {
|
||||
|
||||
/**
|
||||
* Handles a request from the client.
|
||||
*
|
||||
* @param exchange the exchange associated with the client that allows calling
|
||||
* back to the connected client or inspecting its capabilities.
|
||||
* @param params the parameters of the request.
|
||||
* @return a Mono that will emit the response to the request.
|
||||
*/
|
||||
Mono<T> handle(McpAsyncServerExchange exchange, Object params);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating server sessions which delegate to a provided 1:1 transport
|
||||
* with a connected client.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface Factory {
|
||||
|
||||
/**
|
||||
* Creates a new 1:1 representation of the client-server interaction.
|
||||
*
|
||||
* @param sessionTransport the transport to use for communication with the client.
|
||||
* @return a new server session.
|
||||
*/
|
||||
McpServerSession create(McpServerTransport sessionTransport);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* The core building block providing the server-side MCP transport. Implement this
|
||||
* interface to bridge between a particular server-side technology and the MCP server
|
||||
* transport layer.
|
||||
*
|
||||
* <p>
|
||||
* The lifecycle of the provider dictates that it be created first, upon application
|
||||
* startup, and then passed into either
|
||||
* {@link io.modelcontextprotocol.server.McpServer#sync(McpServerTransportProvider)} or
|
||||
* {@link io.modelcontextprotocol.server.McpServer#async(McpServerTransportProvider)}. As
|
||||
* a result of the MCP server creation, the provider will be notified of a
|
||||
* {@link McpServerSession.Factory} which will be used to handle a 1:1 communication
|
||||
* between a newly connected client and the server. The provider's responsibility is to
|
||||
* create instances of {@link McpServerTransport} that the session will utilise during the
|
||||
* session lifetime.
|
||||
*
|
||||
* <p>
|
||||
* Finally, the {@link McpServerTransport}s can be closed in bulk when {@link #close()} or
|
||||
* {@link #closeGracefully()} are called as part of the normal application shutdown event.
|
||||
* Individual {@link McpServerTransport}s can also be closed on a per-session basis, where
|
||||
* the {@link McpServerSession#close()} or {@link McpServerSession#closeGracefully()}
|
||||
* closes the provided transport.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public interface McpServerTransportProvider {
|
||||
|
||||
/**
|
||||
* Sends a notification to all connected clients.
|
||||
* @param method the name of the notification method to be called on the clients
|
||||
* @param params parameters to be sent with the notification
|
||||
* @return a Mono that completes when the notification has been broadcast
|
||||
* @see McpSession#sendNotification(String, Map)
|
||||
*/
|
||||
Mono<Void> notifyClients(String method, Object params);
|
||||
|
||||
/**
|
||||
* Immediately closes all the transports with connected clients and releases any
|
||||
* associated resources.
|
||||
*/
|
||||
default void close() {
|
||||
this.closeGracefully().subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes all the transports with connected clients and releases any
|
||||
* associated resources asynchronously.
|
||||
* @return a {@link Mono<Void>} that completes when the connections have been closed.
|
||||
*/
|
||||
Mono<Void> closeGracefully();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
|
||||
|
||||
/**
|
||||
* Represents a synchronous exchange with a Model Context Protocol (MCP) client. The
|
||||
* exchange provides methods to interact with the client and query its capabilities.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
public class McpSyncServerExchange {
|
||||
|
||||
private final McpAsyncServerExchange exchange;
|
||||
|
||||
/**
|
||||
* Create a new synchronous exchange with the client using the provided asynchronous
|
||||
* implementation as a delegate.
|
||||
* @param exchange The asynchronous exchange to delegate to.
|
||||
*/
|
||||
public McpSyncServerExchange(McpAsyncServerExchange exchange) {
|
||||
this.exchange = exchange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client capabilities that define the supported features and functionality.
|
||||
* @return The client capabilities
|
||||
*/
|
||||
public McpSchema.ClientCapabilities getClientCapabilities() {
|
||||
return this.exchange.getClientCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client implementation information.
|
||||
* @return The client implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getClientInfo() {
|
||||
return this.exchange.getClientInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new message using the sampling capabilities of the client. The Model
|
||||
* Context Protocol (MCP) provides a standardized way for servers to request LLM
|
||||
* sampling (“completions” or “generations”) from language models via clients. This
|
||||
* flow allows clients to maintain control over model access, selection, and
|
||||
* permissions while enabling servers to leverage AI capabilities—with no server API
|
||||
* keys necessary. Servers can request text or image-based interactions and optionally
|
||||
* include context from MCP servers in their prompts.
|
||||
* @param createMessageRequest The request to create a new message
|
||||
* @return A result containing the details of the sampling response
|
||||
* @see McpSchema.CreateMessageRequest
|
||||
* @see McpSchema.CreateMessageResult
|
||||
* @see <a href=
|
||||
* "https://spec.modelcontextprotocol.io/specification/client/sampling/">Sampling
|
||||
* Specification</a>
|
||||
*/
|
||||
public McpSchema.CreateMessageResult createMessage(McpSchema.CreateMessageRequest createMessageRequest) {
|
||||
return this.exchange.createMessage(createMessageRequest).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all roots provided by the client.
|
||||
* @return The list of roots result.
|
||||
*/
|
||||
public McpSchema.ListRootsResult listRoots() {
|
||||
return this.exchange.listRoots().block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of roots provided by the client.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return The list of roots result
|
||||
*/
|
||||
public McpSchema.ListRootsResult listRoots(String cursor) {
|
||||
return this.exchange.listRoots(cursor).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a logging message notification to all connected clients. Messages below the
|
||||
* current minimum logging level will be filtered out.
|
||||
* @param loggingMessageNotification The logging message to send
|
||||
*/
|
||||
public void loggingNotification(LoggingMessageNotification loggingMessageNotification) {
|
||||
this.exchange.loggingNotification(loggingMessageNotification).block();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.xspaceagi.mcp.infra.server;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.xspaceagi.mcp.infra.client.McpAsyncClientWrapper;
|
||||
import com.xspaceagi.mcp.infra.rpc.McpDeployRpcService;
|
||||
import com.xspaceagi.mcp.sdk.IMcpApiService;
|
||||
import com.xspaceagi.mcp.sdk.dto.McpDto;
|
||||
import com.xspaceagi.mcp.sdk.dto.McpExecuteRequest;
|
||||
import com.xspaceagi.mcp.sdk.dto.McpImageContent;
|
||||
import com.xspaceagi.mcp.sdk.dto.McpLogContent;
|
||||
import com.xspaceagi.mcp.sdk.enums.InstallTypeEnum;
|
||||
import com.xspaceagi.mcp.sdk.enums.McpContentTypeEnum;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.application.dto.UserDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.application.service.UserApplicationService;
|
||||
import com.xspaceagi.system.sdk.common.TraceContext;
|
||||
import com.xspaceagi.system.sdk.service.UserAccessKeyApiService;
|
||||
import com.xspaceagi.system.sdk.service.dto.UserAccessKeyDto;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.spec.utils.TimeWheel;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.commons.collections4.CollectionUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.servlet.function.RouterFunction;
|
||||
import org.springframework.web.servlet.function.RouterFunctions;
|
||||
import org.springframework.web.servlet.function.ServerRequest;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WebMvcSseServerTransportProvider implements McpServerTransportProvider {
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebMvcSseServerTransportProvider.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String messageEndpoint;
|
||||
private final String sseEndpoint;
|
||||
private final String baseUrl;
|
||||
private final RouterFunction<ServerResponse> routerFunction;
|
||||
private final ConcurrentHashMap<String, McpServerSession> sessions;
|
||||
private volatile boolean isClosing;
|
||||
|
||||
@Resource
|
||||
private IMcpApiService mcpApiService;
|
||||
|
||||
@Resource
|
||||
private TenantConfigApplicationService tenantConfigApplicationService;
|
||||
|
||||
@Resource
|
||||
private UserApplicationService userApplicationService;
|
||||
|
||||
@Resource
|
||||
private UserAccessKeyApiService userAccessKeyApiService;
|
||||
|
||||
@Resource
|
||||
private McpDeployRpcService deployRpcService;
|
||||
|
||||
@Resource
|
||||
private TimeWheel timeWheel;
|
||||
|
||||
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) {
|
||||
this(objectMapper, "", messageEndpoint, sseEndpoint);
|
||||
}
|
||||
|
||||
public WebMvcSseServerTransportProvider(ObjectMapper objectMapper, String baseUrl, String messageEndpoint, String sseEndpoint) {
|
||||
this.sessions = new ConcurrentHashMap();
|
||||
this.isClosing = false;
|
||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||
Assert.notNull(baseUrl, "Message base URL must not be null");
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
Assert.notNull(sseEndpoint, "SSE endpoint must not be null");
|
||||
this.objectMapper = objectMapper;
|
||||
this.baseUrl = baseUrl;
|
||||
this.messageEndpoint = messageEndpoint;
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
this.routerFunction = RouterFunctions.route().GET(this.sseEndpoint, this::handleSseConnection).POST(this.messageEndpoint, this::handleMessage).build();//.POST(this.messageEndpoint, this::handleMessage)
|
||||
}
|
||||
|
||||
public Mono<Void> notifyClients(String method, Object params) {
|
||||
if (this.sessions.isEmpty()) {
|
||||
logger.debug("No active sessions to broadcast message to");
|
||||
return Mono.empty();
|
||||
} else {
|
||||
logger.debug("Attempting to broadcast message to {} active sessions", this.sessions.size());
|
||||
return Flux.fromIterable(this.sessions.values()).flatMap((session) -> {
|
||||
return session.sendNotification(method, params).doOnError((e) -> {
|
||||
logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage());
|
||||
}).onErrorComplete();
|
||||
}).then();
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Flux.fromIterable(this.sessions.values()).doFirst(() -> {
|
||||
this.isClosing = true;
|
||||
logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size());
|
||||
}).flatMap(McpServerSession::closeGracefully).then().doOnSuccess((v) -> {
|
||||
logger.debug("Graceful shutdown completed");
|
||||
});
|
||||
}
|
||||
|
||||
public RouterFunction<ServerResponse> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
private ServerResponse handleSseConnection(ServerRequest request) {
|
||||
if (this.isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
|
||||
} else {
|
||||
String ak = request.param("ak").orElse(null);
|
||||
UserAccessKeyDto userAccessKeyDto = this.userAccessKeyApiService.queryAccessKey(ak);
|
||||
if (userAccessKeyDto == null || userAccessKeyDto.getTargetType() != UserAccessKeyDto.AKTargetType.Mcp) {
|
||||
return ServerResponse.status(HttpStatus.UNAUTHORIZED).body(new McpError("Invalid ak"));
|
||||
}
|
||||
McpDto deployedMcp = mcpApiService.getDeployedMcp(Long.parseLong(userAccessKeyDto.getTargetId()), null);
|
||||
if (deployedMcp == null) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body(new McpError("Mcp server is unavailable"));
|
||||
}
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
logger.info("Creating new SSE connection for session: {}, mcp id: {}, mcp name: {}", sessionId, deployedMcp.getId(), deployedMcp.getName());
|
||||
|
||||
try {
|
||||
return ServerResponse.sse((sseBuilder) -> {
|
||||
sseBuilder.onComplete(() -> {
|
||||
logger.debug("SSE connection completed for session: {}", sessionId);
|
||||
this.sessions.remove(sessionId);
|
||||
});
|
||||
sseBuilder.onTimeout(() -> {
|
||||
logger.debug("SSE connection timed out for session: {}", sessionId);
|
||||
this.sessions.remove(sessionId);
|
||||
});
|
||||
sseBuilder.onError(throwable -> {
|
||||
logger.error("SSE connection error for session: {}", sessionId, throwable);
|
||||
this.sessions.remove(sessionId);
|
||||
});
|
||||
WebMvcMcpSessionTransport sessionTransport = new WebMvcMcpSessionTransport(sessionId, sseBuilder);
|
||||
McpAsyncServer mcpAsyncServer = buildMcpAsyncServer(userAccessKeyDto, deployedMcp, sessionId);
|
||||
McpServerSession session = mcpAsyncServer.getSessionFactory().create(sessionTransport);
|
||||
session.setMcpAsyncServer(mcpAsyncServer);
|
||||
this.sessions.put(sessionId, session);
|
||||
session.startObserving(timeWheel, (s) -> {
|
||||
logger.debug("SSE connection not alive for session: {}", sessionId);
|
||||
McpServerSession removeSession = this.sessions.remove(sessionId);
|
||||
if (removeSession != null) {
|
||||
removeSession.close();
|
||||
}
|
||||
mcpAsyncServer.close();
|
||||
});
|
||||
|
||||
try {
|
||||
sseBuilder.id(sessionId).event("endpoint").data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
|
||||
} catch (Exception var6) {
|
||||
logger.error("Failed to send initial endpoint event: {}", var6.getMessage());
|
||||
sseBuilder.error(var6);
|
||||
}
|
||||
|
||||
}, Duration.ZERO);
|
||||
} catch (Exception var4) {
|
||||
logger.error("Failed to send initial endpoint event to session {}: {}", sessionId, var4.getMessage());
|
||||
this.sessions.remove(sessionId);
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private McpAsyncServer buildMcpAsyncServer(UserAccessKeyDto userAccessKeyDto, McpDto deployedMcp, String sessionId) {
|
||||
//代理mcp
|
||||
if (deployedMcp.getInstallType() != InstallTypeEnum.COMPONENT) {
|
||||
Mono<McpAsyncClientWrapper> mcpAsyncClientMono;
|
||||
if (deployedMcp.getInstallType() == InstallTypeEnum.SSE) {
|
||||
mcpAsyncClientMono = deployRpcService.getMcpAsyncClientForSSE(deployedMcp.getId().toString(), "mcp-server-" + deployedMcp.getId(), deployedMcp.getDeployedConfig().getServerConfig());
|
||||
} else {
|
||||
mcpAsyncClientMono = deployRpcService.getMcpAsyncClient(deployedMcp.getId().toString(), "mcp-server-" + deployedMcp.getId(), deployedMcp.getDeployedConfig().getServerConfig());
|
||||
}
|
||||
McpAsyncServer mcpAsyncServer = McpServer.async(this).build(true);
|
||||
mcpAsyncServer.setMcpProxyAsyncClientMono(mcpAsyncClientMono);
|
||||
return mcpAsyncServer;
|
||||
}
|
||||
|
||||
//平台组件mcp
|
||||
UserDto userDto = userApplicationService.queryById(userAccessKeyDto.getUserId());
|
||||
TenantConfigDto tenantConfigDto = tenantConfigApplicationService.getTenantConfig(userDto.getTenantId());
|
||||
McpServer.AsyncSpecification capabilities = McpServer.async(this)
|
||||
.serverInfo(deployedMcp.getServerName() == null ? deployedMcp.getName() : deployedMcp.getServerName(), "1.0.0")
|
||||
.capabilities(McpSchema.ServerCapabilities.builder()
|
||||
.resources(false, false) // 启用资源支持
|
||||
.tools(true) // 启用工具支持
|
||||
.prompts(false) // 启用提示支持
|
||||
.logging() // 启用日志支持
|
||||
.build());
|
||||
//平台组件只支持tools
|
||||
List<McpServerFeatures.AsyncToolSpecification> asyncToolSpecifications = new ArrayList<>();
|
||||
deployedMcp.getDeployedConfig().getTools().forEach(tool -> {
|
||||
var aSyncToolSpecification = new McpServerFeatures.AsyncToolSpecification(new McpSchema.Tool(tool.getName(), tool.getDescription(), tool.getJsonSchema()),
|
||||
(exchange, args) -> {
|
||||
TraceContext traceContext = userAccessKeyDto.getConfig().getTraceContext();
|
||||
String traceId;
|
||||
String conversationId;
|
||||
if (traceContext == null) {
|
||||
traceId = UUID.randomUUID().toString().replace("-", "");
|
||||
conversationId = sessionId;
|
||||
traceContext = TraceContext.builder()
|
||||
.traceId(traceId)
|
||||
.tenantId(userDto.getTenantId())
|
||||
.userId(userDto.getId())
|
||||
.billUserId(userDto.getId())
|
||||
.userName(userDto.getUserName())
|
||||
.nickName(userDto.getNickName())
|
||||
.conversationId(sessionId)
|
||||
.enableSubscription(tenantConfigDto.getEnableSubscription() != null && tenantConfigDto.getEnableSubscription() == 1)
|
||||
.traceTargets(Lists.newArrayList(TraceContext.TraceTarget.builder()
|
||||
.targetId(deployedMcp.getId().toString())
|
||||
.targetType(TraceContext.TraceTargetType.Mcp)
|
||||
.icon(deployedMcp.getIcon())
|
||||
.description(deployedMcp.getDescription())
|
||||
.name(deployedMcp.getName())
|
||||
.build()))
|
||||
.build();
|
||||
} else {
|
||||
traceId = traceContext.getTraceId();
|
||||
conversationId = traceContext.getConversationId();
|
||||
}
|
||||
McpExecuteRequest mcpExecuteRequest = McpExecuteRequest.builder()
|
||||
.requestId(traceId)
|
||||
.sessionId(conversationId)
|
||||
.mcpDto(deployedMcp)
|
||||
.executeType(McpExecuteRequest.ExecuteTypeEnum.TOOL)
|
||||
.name(tool.getName())
|
||||
.keepAlive(true)
|
||||
.user(userDto)
|
||||
.traceContext(traceContext)
|
||||
.params(args)
|
||||
.build();
|
||||
return Mono.create(sink -> mcpApiService.execute(mcpExecuteRequest).doOnNext(mcpExecuteOutput -> {
|
||||
if (!mcpExecuteOutput.isSuccess()) {
|
||||
sink.error(BizException.of(ErrorCodeEnum.ERROR_REQUEST, BizExceptionCodeEnum.remoteServiceMessage,
|
||||
mcpExecuteOutput.getMessage() != null ? mcpExecuteOutput.getMessage() : ""));
|
||||
} else if (CollectionUtils.isNotEmpty(mcpExecuteOutput.getResult()) && !(mcpExecuteOutput.getResult().get(0) instanceof McpLogContent)) {
|
||||
sink.success(new McpSchema.CallToolResult(mcpExecuteOutput.getResult().stream().map(mcpContent -> {
|
||||
if (mcpContent.getType() == McpContentTypeEnum.TEXT) {
|
||||
return new McpSchema.TextContent(mcpContent.getData());
|
||||
}
|
||||
if (mcpContent.getType() == McpContentTypeEnum.IMAGE) {
|
||||
McpImageContent mcpImageContent = (McpImageContent) mcpContent;
|
||||
return new McpSchema.ImageContent(List.of(McpSchema.Role.ASSISTANT), mcpImageContent.getPriority(), mcpImageContent.getData(), mcpImageContent.getMimeType());
|
||||
}
|
||||
return null;
|
||||
}).collect(Collectors.toList()), false));
|
||||
} else {
|
||||
//LOG
|
||||
if (CollectionUtils.isNotEmpty(mcpExecuteOutput.getResult()) && (mcpExecuteOutput.getResult().get(0) instanceof McpLogContent)) {
|
||||
exchange.loggingNotification( // Use the exchange to send log messages
|
||||
McpSchema.LoggingMessageNotification.builder()
|
||||
.level(McpSchema.LoggingLevel.DEBUG)
|
||||
.logger(tool.getName())
|
||||
.data(JSON.toJSONString(mcpExecuteOutput.getResult()))
|
||||
.build())
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
}).doOnError(sink::error).subscribe());
|
||||
}
|
||||
);
|
||||
asyncToolSpecifications.add(aSyncToolSpecification);
|
||||
});
|
||||
|
||||
return capabilities.tools(asyncToolSpecifications).build(false);
|
||||
}
|
||||
|
||||
private ServerResponse handleMessage(ServerRequest request) {//改造成异步
|
||||
return ServerResponse.async(Mono.create(sink -> {
|
||||
if (this.isClosing) {
|
||||
sink.success(ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down"));
|
||||
} else if (!request.param("sessionId").isPresent()) {
|
||||
sink.success(ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint")));
|
||||
} else {
|
||||
String sessionId = request.param("sessionId").get();
|
||||
McpServerSession session = this.sessions.get(sessionId);
|
||||
if (session == null) {
|
||||
logger.debug("Session not found: {}", sessionId);
|
||||
sink.success(ServerResponse.status(HttpStatus.NOT_FOUND).body(new McpError("Session not found: " + sessionId)));
|
||||
} else {
|
||||
try {
|
||||
String body = request.body(String.class);
|
||||
logger.info("Received mcp message: {}, sessionId :{}", body, sessionId);
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.objectMapper, body);
|
||||
session.handle(message)
|
||||
.onErrorResume(error -> {
|
||||
logger.error("onErrorResume handling, mcp message: {}, err {}", message, error.getMessage());
|
||||
return Mono.just(new McpError(error)).then();
|
||||
})
|
||||
.doOnSuccess(res -> {
|
||||
logger.debug("Sent mcp message: {}, sessionId :{}", message, sessionId);
|
||||
sink.success(ServerResponse.ok().build());
|
||||
})
|
||||
.doOnError(error -> {
|
||||
logger.error("Error handling, mcp message: {}, err {}", message, error.getMessage());
|
||||
sink.success(ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(error.getMessage())));
|
||||
})
|
||||
.subscribe();
|
||||
} catch (IOException | IllegalArgumentException var6) {
|
||||
logger.error("Failed to deserialize message: {}", var6.getMessage());
|
||||
sink.success(ServerResponse.badRequest().body(new McpError("Invalid message format")));
|
||||
} catch (Exception var7) {
|
||||
logger.error("Error handling message: {}", var7.getMessage());
|
||||
sink.success(ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new McpError(var7.getMessage())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}), Duration.ofMinutes(30));
|
||||
}
|
||||
|
||||
private class WebMvcMcpSessionTransport implements McpServerTransport {
|
||||
private final String sessionId;
|
||||
private final ServerResponse.SseBuilder sseBuilder;
|
||||
|
||||
WebMvcMcpSessionTransport(String sessionId, ServerResponse.SseBuilder sseBuilder) {
|
||||
this.sessionId = sessionId;
|
||||
this.sseBuilder = sseBuilder;
|
||||
WebMvcSseServerTransportProvider.logger.debug("Session transport {} initialized with SSE builder", sessionId);
|
||||
}
|
||||
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
try {
|
||||
String jsonText = WebMvcSseServerTransportProvider.this.objectMapper.writeValueAsString(message);
|
||||
this.sseBuilder.id(this.sessionId).event("message").data(jsonText);
|
||||
logger.info("Sending message to session {}: {}", this.sessionId, jsonText);
|
||||
WebMvcSseServerTransportProvider.logger.debug("Message sent to session {}", this.sessionId);
|
||||
} catch (Exception var3) {
|
||||
WebMvcSseServerTransportProvider.logger.error("Failed to send message to session {}: {}", this.sessionId, var3.getMessage());
|
||||
this.sseBuilder.error(var3);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return WebMvcSseServerTransportProvider.this.objectMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> {
|
||||
WebMvcSseServerTransportProvider.logger.debug("Closing session transport: {}", this.sessionId);
|
||||
|
||||
try {
|
||||
this.sseBuilder.complete();
|
||||
WebMvcSseServerTransportProvider.logger.debug("Successfully completed SSE builder for session {}", this.sessionId);
|
||||
} catch (Exception var2) {
|
||||
WebMvcSseServerTransportProvider.logger.warn("Failed to complete SSE builder for session {}: {}", this.sessionId, var2.getMessage());
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public void close() {
|
||||
try {
|
||||
this.sseBuilder.complete();
|
||||
WebMvcSseServerTransportProvider.logger.debug("Successfully completed SSE builder for session {}", this.sessionId);
|
||||
} catch (Exception var2) {
|
||||
WebMvcSseServerTransportProvider.logger.warn("Failed to complete SSE builder for session {}: {}", this.sessionId, var2.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user