chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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>

View File

@@ -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
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
});
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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> {
}

View File

@@ -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 {
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
package com.xspaceagi.mcp.infra.rpc.enums;
public enum McpDeployStatusEnum {
Ready, Pending, Error
}

View File

@@ -0,0 +1,5 @@
package com.xspaceagi.mcp.infra.rpc.enums;
public enum McpPersistentTypeEnum {
Persistent, OneShot
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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) {
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}
}
}