extract mcp-core (#557)
* extract mcp-core - This change extracts classes previously in `mcp` to `mcp-core`; a new module which does not depend on `mcp-json-jackon2`. - `mcp` module is now an umbrella module that brings `mcp-json-jackon2` and `mcp-core` * add mcp-core to bom BREAKING CHANGE: All source code in the previously `mcp` module is moved to the `mcp-core`. the `mcp` now is just an aggregation for `mcp-core` + `mcp-json-jackson2` for backward compatibility. This change won't affect the end users, but will cause conflicts for the existing PRs.
This commit is contained in:
parent
80d0ad82a6
commit
1499df22c7
70 changed files with 246 additions and 151 deletions
|
|
@ -1,354 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpClientSession;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.util.context.ContextView;
|
||||
|
||||
/**
|
||||
* <b>Handles the protocol initialization phase between client and server</b>
|
||||
*
|
||||
* <p>
|
||||
* The initialization phase MUST be the first interaction between client and server.
|
||||
* During this phase, the client and server perform the following operations:
|
||||
* <ul>
|
||||
* <li>Establish protocol version compatibility</li>
|
||||
* <li>Exchange and negotiate capabilities</li>
|
||||
* <li>Share implementation details</li>
|
||||
* </ul>
|
||||
*
|
||||
* <b>Client Initialization Process</b>
|
||||
* <p>
|
||||
* The client MUST initiate this phase by sending an initialize request containing:
|
||||
* <ul>
|
||||
* <li>Protocol version supported</li>
|
||||
* <li>Client capabilities</li>
|
||||
* <li>Client implementation information</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* After successful initialization, the client MUST send an initialized notification to
|
||||
* indicate it is ready to begin normal operations.
|
||||
*
|
||||
* <b>Server Response</b>
|
||||
* <p>
|
||||
* The server MUST respond with its own capabilities and information.
|
||||
*
|
||||
* <b>Protocol Version Negotiation</b>
|
||||
* <p>
|
||||
* In the initialize request, the client MUST send a protocol version it supports. This
|
||||
* SHOULD be the latest version supported by the client.
|
||||
*
|
||||
* <p>
|
||||
* If the server supports the requested protocol version, it MUST respond with the same
|
||||
* version. Otherwise, the server MUST respond with another protocol version it supports.
|
||||
* This SHOULD be the latest version supported by the server.
|
||||
*
|
||||
* <p>
|
||||
* If the client does not support the version in the server's response, it SHOULD
|
||||
* disconnect.
|
||||
*
|
||||
* <b>Request Restrictions</b>
|
||||
* <p>
|
||||
* <strong>Important:</strong> The following restrictions apply during initialization:
|
||||
* <ul>
|
||||
* <li>The client SHOULD NOT send requests other than pings before the server has
|
||||
* responded to the initialize request</li>
|
||||
* <li>The server SHOULD NOT send requests other than pings and logging before receiving
|
||||
* the initialized notification</li>
|
||||
* </ul>
|
||||
*/
|
||||
class LifecycleInitializer {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LifecycleInitializer.class);
|
||||
|
||||
/**
|
||||
* The MCP session supplier that manages bidirectional JSON-RPC communication between
|
||||
* clients and servers.
|
||||
*/
|
||||
private final Function<ContextView, McpClientSession> sessionSupplier;
|
||||
|
||||
private final McpSchema.ClientCapabilities clientCapabilities;
|
||||
|
||||
private final McpSchema.Implementation clientInfo;
|
||||
|
||||
private List<String> protocolVersions;
|
||||
|
||||
private final AtomicReference<DefaultInitialization> initializationRef = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* The max timeout to await for the client-server connection to be initialized.
|
||||
*/
|
||||
private final Duration initializationTimeout;
|
||||
|
||||
public LifecycleInitializer(McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
|
||||
List<String> protocolVersions, Duration initializationTimeout,
|
||||
Function<ContextView, McpClientSession> sessionSupplier) {
|
||||
|
||||
Assert.notNull(sessionSupplier, "Session supplier must not be null");
|
||||
Assert.notNull(clientCapabilities, "Client capabilities must not be null");
|
||||
Assert.notNull(clientInfo, "Client info must not be null");
|
||||
Assert.notEmpty(protocolVersions, "Protocol versions must not be empty");
|
||||
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
|
||||
|
||||
this.sessionSupplier = sessionSupplier;
|
||||
this.clientCapabilities = clientCapabilities;
|
||||
this.clientInfo = clientInfo;
|
||||
this.protocolVersions = Collections.unmodifiableList(new ArrayList<>(protocolVersions));
|
||||
this.initializationTimeout = initializationTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the initialization state of the MCP client.
|
||||
*/
|
||||
interface Initialization {
|
||||
|
||||
/**
|
||||
* Returns the MCP client session that is used to communicate with the server.
|
||||
* This session is established during the initialization process and is used for
|
||||
* sending requests and notifications.
|
||||
* @return The MCP client session
|
||||
*/
|
||||
McpClientSession mcpSession();
|
||||
|
||||
/**
|
||||
* Returns the result of the MCP initialization process. This result contains
|
||||
* information about the protocol version, capabilities, server info, and
|
||||
* instructions provided by the server during the initialization phase.
|
||||
* @return The result of the MCP initialization process
|
||||
*/
|
||||
McpSchema.InitializeResult initializeResult();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of the {@link Initialization} interface that manages the MCP
|
||||
* client initialization process.
|
||||
*/
|
||||
private static class DefaultInitialization implements Initialization {
|
||||
|
||||
/**
|
||||
* A sink that emits the result of the MCP initialization process. It allows
|
||||
* subscribers to wait for the initialization to complete.
|
||||
*/
|
||||
private final Sinks.One<McpSchema.InitializeResult> initSink;
|
||||
|
||||
/**
|
||||
* Holds the result of the MCP initialization process. It is used to cache the
|
||||
* result for future requests.
|
||||
*/
|
||||
private final AtomicReference<McpSchema.InitializeResult> result;
|
||||
|
||||
/**
|
||||
* Holds the MCP client session that is used to communicate with the server. It is
|
||||
* set during the initialization process and used for sending requests and
|
||||
* notifications.
|
||||
*/
|
||||
private final AtomicReference<McpClientSession> mcpClientSession;
|
||||
|
||||
private DefaultInitialization() {
|
||||
this.initSink = Sinks.one();
|
||||
this.result = new AtomicReference<>();
|
||||
this.mcpClientSession = new AtomicReference<>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------
|
||||
// Public access for mcpSession and initializeResult because they are
|
||||
// used in by the McpAsyncClient.
|
||||
// ----------------------------------------------------
|
||||
public McpClientSession mcpSession() {
|
||||
return this.mcpClientSession.get();
|
||||
}
|
||||
|
||||
public McpSchema.InitializeResult initializeResult() {
|
||||
return this.result.get();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------
|
||||
// Private accessors used internally by the LifecycleInitializer to set the MCP
|
||||
// client session and complete the initialization process.
|
||||
// ---------------------------------------------------
|
||||
private void setMcpClientSession(McpClientSession mcpClientSession) {
|
||||
this.mcpClientSession.set(mcpClientSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Mono that completes when the MCP client initialization is complete.
|
||||
* This allows subscribers to wait for the initialization to finish before
|
||||
* proceeding with further operations.
|
||||
* @return A Mono that emits the result of the MCP initialization process
|
||||
*/
|
||||
private Mono<McpSchema.InitializeResult> await() {
|
||||
return this.initSink.asMono();
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the initialization process with the given result. It caches the
|
||||
* result and emits it to all subscribers waiting for the initialization to
|
||||
* complete.
|
||||
* @param initializeResult The result of the MCP initialization process
|
||||
*/
|
||||
private void complete(McpSchema.InitializeResult initializeResult) {
|
||||
// first ensure the result is cached
|
||||
this.result.set(initializeResult);
|
||||
// inform all the subscribers waiting for the initialization
|
||||
this.initSink.emitValue(initializeResult, Sinks.EmitFailureHandler.FAIL_FAST);
|
||||
}
|
||||
|
||||
private void error(Throwable t) {
|
||||
this.initSink.emitError(t, Sinks.EmitFailureHandler.FAIL_FAST);
|
||||
}
|
||||
|
||||
private void close() {
|
||||
this.mcpSession().close();
|
||||
}
|
||||
|
||||
private Mono<Void> closeGracefully() {
|
||||
return this.mcpSession().closeGracefully();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return this.currentInitializationResult() != null;
|
||||
}
|
||||
|
||||
public McpSchema.InitializeResult currentInitializationResult() {
|
||||
DefaultInitialization current = this.initializationRef.get();
|
||||
McpSchema.InitializeResult initializeResult = current != null ? current.result.get() : null;
|
||||
return initializeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle exceptions that occur during the MCP transport session.
|
||||
* <p>
|
||||
* If the exception is a {@link McpTransportSessionNotFoundException}, it indicates
|
||||
* that the session was not found, and we should re-initialize the client.
|
||||
* </p>
|
||||
* @param t The exception to handle
|
||||
*/
|
||||
public void handleException(Throwable t) {
|
||||
logger.warn("Handling exception", t);
|
||||
if (t instanceof McpTransportSessionNotFoundException) {
|
||||
DefaultInitialization previous = this.initializationRef.getAndSet(null);
|
||||
if (previous != null) {
|
||||
previous.close();
|
||||
}
|
||||
// Providing an empty operation since we are only interested in triggering
|
||||
// the implicit initialization step.
|
||||
withIntitialization("re-initializing", result -> Mono.empty()).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to ensure the initialization is established before executing an
|
||||
* operation.
|
||||
* @param <T> The type of the result Mono
|
||||
* @param actionName The action to perform when the client is initialized
|
||||
* @param operation The operation to execute when the client is initialized
|
||||
* @return A Mono that completes with the result of the operation
|
||||
*/
|
||||
public <T> Mono<T> withIntitialization(String actionName, Function<Initialization, Mono<T>> operation) {
|
||||
return Mono.deferContextual(ctx -> {
|
||||
DefaultInitialization newInit = new DefaultInitialization();
|
||||
DefaultInitialization previous = this.initializationRef.compareAndExchange(null, newInit);
|
||||
|
||||
boolean needsToInitialize = previous == null;
|
||||
logger.debug(needsToInitialize ? "Initialization process started" : "Joining previous initialization");
|
||||
|
||||
Mono<McpSchema.InitializeResult> initializationJob = needsToInitialize ? doInitialize(newInit, ctx)
|
||||
: previous.await();
|
||||
|
||||
return initializationJob.map(initializeResult -> this.initializationRef.get())
|
||||
.timeout(this.initializationTimeout)
|
||||
.onErrorResume(ex -> {
|
||||
this.initializationRef.compareAndSet(newInit, null);
|
||||
return Mono.error(new RuntimeException("Client failed to initialize " + actionName, ex));
|
||||
})
|
||||
.flatMap(operation);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<McpSchema.InitializeResult> doInitialize(DefaultInitialization initialization, ContextView ctx) {
|
||||
initialization.setMcpClientSession(this.sessionSupplier.apply(ctx));
|
||||
|
||||
McpClientSession mcpClientSession = initialization.mcpSession();
|
||||
|
||||
String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
|
||||
|
||||
McpSchema.InitializeRequest initializeRequest = new McpSchema.InitializeRequest(latestVersion,
|
||||
this.clientCapabilities, this.clientInfo);
|
||||
|
||||
Mono<McpSchema.InitializeResult> result = mcpClientSession.sendRequest(McpSchema.METHOD_INITIALIZE,
|
||||
initializeRequest, McpAsyncClient.INITIALIZE_RESULT_TYPE_REF);
|
||||
|
||||
return result.flatMap(initializeResult -> {
|
||||
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(McpError.builder(-32602)
|
||||
.message("Unsupported protocol version")
|
||||
.data("Unsupported protocol version from the server: " + initializeResult.protocolVersion())
|
||||
.build());
|
||||
}
|
||||
|
||||
return mcpClientSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
|
||||
.thenReturn(initializeResult);
|
||||
}).doOnNext(initialization::complete).onErrorResume(ex -> {
|
||||
initialization.error(ex);
|
||||
return Mono.error(ex);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the current initialization if it exists.
|
||||
*/
|
||||
public void close() {
|
||||
DefaultInitialization current = this.initializationRef.getAndSet(null);
|
||||
if (current != null) {
|
||||
current.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the current initialization if it exists.
|
||||
* @return A Mono that completes when the connection is closed
|
||||
*/
|
||||
public Mono<?> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
DefaultInitialization current = this.initializationRef.getAndSet(null);
|
||||
Mono<?> sessionClose = current != null ? current.closeGracefully() : Mono.empty();
|
||||
return sessionClose;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,931 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpClientSession;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
|
||||
import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Root;
|
||||
import io.modelcontextprotocol.spec.McpClientSession.NotificationHandler;
|
||||
import io.modelcontextprotocol.spec.McpClientSession.RequestHandler;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @author Jihoon Kim
|
||||
* @see McpClient
|
||||
* @see McpSchema
|
||||
* @see McpClientSession
|
||||
* @see McpClientTransport
|
||||
*/
|
||||
public class McpAsyncClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpAsyncClient.class);
|
||||
|
||||
private static final TypeRef<Void> VOID_TYPE_REFERENCE = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<Object> OBJECT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<PaginatedRequest> PAGINATED_REQUEST_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<McpSchema.InitializeResult> INITIALIZE_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<CreateMessageRequest> CREATE_MESSAGE_REQUEST_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<LoggingMessageNotification> LOGGING_MESSAGE_NOTIFICATION_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<McpSchema.ProgressNotification> PROGRESS_NOTIFICATION_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Client capabilities.
|
||||
*/
|
||||
private final McpSchema.ClientCapabilities clientCapabilities;
|
||||
|
||||
/**
|
||||
* Client implementation information.
|
||||
*/
|
||||
private final McpSchema.Implementation clientInfo;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* MCP provides a standardized way for servers to request additional information from
|
||||
* users through the client during interactions. This flow allows clients to maintain
|
||||
* control over user interactions and data sharing while enabling servers to gather
|
||||
* necessary information dynamically. Servers can request structured data from users
|
||||
* with optional JSON schemas to validate responses.
|
||||
*/
|
||||
private Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler;
|
||||
|
||||
/**
|
||||
* Client transport implementation.
|
||||
*/
|
||||
private final McpClientTransport transport;
|
||||
|
||||
/**
|
||||
* The lifecycle initializer that manages the client-server connection initialization.
|
||||
*/
|
||||
private final LifecycleInitializer initializer;
|
||||
|
||||
/**
|
||||
* 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());
|
||||
|
||||
// Request Handlers
|
||||
Map<String, RequestHandler<?>> requestHandlers = new HashMap<>();
|
||||
|
||||
// Ping MUST respond with an empty data, but not NULL response.
|
||||
requestHandlers.put(McpSchema.METHOD_PING, params -> {
|
||||
logger.debug("Received ping: {}", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
return Mono.just(Map.of());
|
||||
});
|
||||
|
||||
// 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 IllegalArgumentException(
|
||||
"Sampling handler must not be null when client capabilities include sampling");
|
||||
}
|
||||
this.samplingHandler = features.samplingHandler();
|
||||
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
|
||||
}
|
||||
|
||||
// Elicitation Handler
|
||||
if (this.clientCapabilities.elicitation() != null) {
|
||||
if (features.elicitationHandler() == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Elicitation handler must not be null when client capabilities include elicitation");
|
||||
}
|
||||
this.elicitationHandler = features.elicitationHandler();
|
||||
requestHandlers.put(McpSchema.METHOD_ELICITATION_CREATE, elicitationCreateHandler());
|
||||
}
|
||||
|
||||
// Notification Handlers
|
||||
Map<String, 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));
|
||||
|
||||
// Resources Update Notification
|
||||
List<Function<List<McpSchema.ResourceContents>, Mono<Void>>> resourcesUpdateConsumersFinal = new ArrayList<>();
|
||||
resourcesUpdateConsumersFinal
|
||||
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Resources updated: {}", notification)));
|
||||
|
||||
if (!Utils.isEmpty(features.resourcesUpdateConsumers())) {
|
||||
resourcesUpdateConsumersFinal.addAll(features.resourcesUpdateConsumers());
|
||||
}
|
||||
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED,
|
||||
asyncResourcesUpdatedNotificationHandler(resourcesUpdateConsumersFinal));
|
||||
|
||||
// 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));
|
||||
|
||||
// Utility Progress Notification
|
||||
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumersFinal = new ArrayList<>();
|
||||
progressConsumersFinal
|
||||
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Progress: {}", notification)));
|
||||
if (!Utils.isEmpty(features.progressConsumers())) {
|
||||
progressConsumersFinal.addAll(features.progressConsumers());
|
||||
}
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROGRESS,
|
||||
asyncProgressNotificationHandler(progressConsumersFinal));
|
||||
|
||||
this.initializer = new LifecycleInitializer(clientCapabilities, clientInfo, transport.protocolVersions(),
|
||||
initializationTimeout, ctx -> new McpClientSession(requestTimeout, transport, requestHandlers,
|
||||
notificationHandlers, con -> con.contextWrite(ctx)));
|
||||
this.transport.setExceptionHandler(this.initializer::handleException);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current initialization result.
|
||||
* @return the initialization result.
|
||||
*/
|
||||
public McpSchema.InitializeResult getCurrentInitializationResult() {
|
||||
return this.initializer.currentInitializationResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server capabilities that define the supported features and functionality.
|
||||
* @return The server capabilities
|
||||
*/
|
||||
public McpSchema.ServerCapabilities getServerCapabilities() {
|
||||
McpSchema.InitializeResult initializeResult = this.initializer.currentInitializationResult();
|
||||
return initializeResult != null ? initializeResult.capabilities() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server instructions that provide guidance to the client on how to interact
|
||||
* with this server.
|
||||
* @return The server instructions
|
||||
*/
|
||||
public String getServerInstructions() {
|
||||
McpSchema.InitializeResult initializeResult = this.initializer.currentInitializationResult();
|
||||
return initializeResult != null ? initializeResult.instructions() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server implementation information.
|
||||
* @return The server implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getServerInfo() {
|
||||
McpSchema.InitializeResult initializeResult = this.initializer.currentInitializationResult();
|
||||
return initializeResult != null ? initializeResult.serverInfo() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client-server connection is initialized.
|
||||
* @return true if the client-server connection is initialized
|
||||
*/
|
||||
public boolean isInitialized() {
|
||||
return this.initializer.isInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.initializer.close();
|
||||
this.transport.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the client connection.
|
||||
* @return A Mono that completes when the connection is closed
|
||||
*/
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
return this.initializer.closeGracefully().then(transport.closeGracefully());
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Initialization
|
||||
// --------------------------
|
||||
/**
|
||||
* The initialization phase should be the first interaction between client and server.
|
||||
* The client will ensure it happens in case it has not been explicitly called and in
|
||||
* case of transport session invalidation.
|
||||
* <p>
|
||||
* 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>
|
||||
* </p>
|
||||
*/
|
||||
public Mono<McpSchema.InitializeResult> initialize() {
|
||||
return this.initializer.withIntitialization("by explicit API call", init -> Mono.just(init.initializeResult()));
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// 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.initializer.withIntitialization("pinging the server",
|
||||
init -> init.mcpSession().sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF));
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// 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 IllegalArgumentException("Root must not be null"));
|
||||
}
|
||||
|
||||
if (this.clientCapabilities.roots() == null) {
|
||||
return Mono.error(new IllegalStateException("Client must be configured with roots capabilities"));
|
||||
}
|
||||
|
||||
if (this.roots.containsKey(root.uri())) {
|
||||
return Mono.error(new IllegalStateException("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 IllegalArgumentException("Root uri must not be null"));
|
||||
}
|
||||
|
||||
if (this.clientCapabilities.roots() == null) {
|
||||
return Mono.error(new IllegalStateException("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 IllegalStateException("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.initializer.withIntitialization("sending roots list changed notification",
|
||||
init -> init.mcpSession().sendNotification(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED));
|
||||
}
|
||||
|
||||
private RequestHandler<McpSchema.ListRootsResult> rootsListRequestHandler() {
|
||||
return params -> {
|
||||
@SuppressWarnings("unused")
|
||||
McpSchema.PaginatedRequest request = transport.unmarshalFrom(params, PAGINATED_REQUEST_TYPE_REF);
|
||||
|
||||
List<Root> roots = this.roots.values().stream().toList();
|
||||
|
||||
return Mono.just(new McpSchema.ListRootsResult(roots));
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Sampling
|
||||
// --------------------------
|
||||
private RequestHandler<CreateMessageResult> samplingCreateMessageHandler() {
|
||||
return params -> {
|
||||
McpSchema.CreateMessageRequest request = transport.unmarshalFrom(params, CREATE_MESSAGE_REQUEST_TYPE_REF);
|
||||
|
||||
return this.samplingHandler.apply(request);
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Elicitation
|
||||
// --------------------------
|
||||
private RequestHandler<ElicitResult> elicitationCreateHandler() {
|
||||
return params -> {
|
||||
ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
|
||||
});
|
||||
|
||||
return this.elicitationHandler.apply(request);
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Tools
|
||||
// --------------------------
|
||||
private static final TypeRef<McpSchema.CallToolResult> CALL_TOOL_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
private static final TypeRef<McpSchema.ListToolsResult> LIST_TOOLS_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.initializer.withIntitialization("calling tools", init -> {
|
||||
if (init.initializeResult().capabilities().tools() == null) {
|
||||
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
|
||||
}
|
||||
return init.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 all tools result
|
||||
*/
|
||||
public Mono<McpSchema.ListToolsResult> listTools() {
|
||||
return this.listTools(McpSchema.FIRST_PAGE)
|
||||
.expand(result -> (result.nextCursor() != null) ? this.listTools(result.nextCursor()) : Mono.empty())
|
||||
.reduce(new McpSchema.ListToolsResult(new ArrayList<>(), null), (allToolsResult, result) -> {
|
||||
allToolsResult.tools().addAll(result.tools());
|
||||
return allToolsResult;
|
||||
})
|
||||
.map(result -> new McpSchema.ListToolsResult(Collections.unmodifiableList(result.tools()), 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.initializer.withIntitialization("listing tools", init -> {
|
||||
if (init.initializeResult().capabilities().tools() == null) {
|
||||
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
|
||||
}
|
||||
return init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
LIST_TOOLS_RESULT_TYPE_REF);
|
||||
});
|
||||
}
|
||||
|
||||
private 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 TypeRef<McpSchema.ListResourcesResult> LIST_RESOURCES_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
private static final TypeRef<McpSchema.ReadResourceResult> READ_RESOURCE_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
private static final TypeRef<McpSchema.ListResourceTemplatesResult> LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 all resources result
|
||||
* @see McpSchema.ListResourcesResult
|
||||
* @see #readResource(McpSchema.Resource)
|
||||
*/
|
||||
public Mono<McpSchema.ListResourcesResult> listResources() {
|
||||
return this.listResources(McpSchema.FIRST_PAGE)
|
||||
.expand(result -> (result.nextCursor() != null) ? this.listResources(result.nextCursor()) : Mono.empty())
|
||||
.reduce(new McpSchema.ListResourcesResult(new ArrayList<>(), null), (allResourcesResult, result) -> {
|
||||
allResourcesResult.resources().addAll(result.resources());
|
||||
return allResourcesResult;
|
||||
})
|
||||
.map(result -> new McpSchema.ListResourcesResult(Collections.unmodifiableList(result.resources()), 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.initializer.withIntitialization("listing resources", init -> {
|
||||
if (init.initializeResult().capabilities().resources() == null) {
|
||||
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
|
||||
}
|
||||
return init.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.initializer.withIntitialization("reading resources", init -> {
|
||||
if (init.initializeResult().capabilities().resources() == null) {
|
||||
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
|
||||
}
|
||||
return init.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 all resource templates result
|
||||
* @see McpSchema.ListResourceTemplatesResult
|
||||
*/
|
||||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
|
||||
return this.listResourceTemplates(McpSchema.FIRST_PAGE)
|
||||
.expand(result -> (result.nextCursor() != null) ? this.listResourceTemplates(result.nextCursor())
|
||||
: Mono.empty())
|
||||
.reduce(new McpSchema.ListResourceTemplatesResult(new ArrayList<>(), null),
|
||||
(allResourceTemplatesResult, result) -> {
|
||||
allResourceTemplatesResult.resourceTemplates().addAll(result.resourceTemplates());
|
||||
return allResourceTemplatesResult;
|
||||
})
|
||||
.map(result -> new McpSchema.ListResourceTemplatesResult(
|
||||
Collections.unmodifiableList(result.resourceTemplates()), 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.initializer.withIntitialization("listing resource templates", init -> {
|
||||
if (init.initializeResult().capabilities().resources() == null) {
|
||||
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
|
||||
}
|
||||
return init.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.initializer.withIntitialization("subscribing to resources", init -> init.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.initializer.withIntitialization("unsubscribing from resources", init -> init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, unsubscribeRequest, VOID_TYPE_REFERENCE));
|
||||
}
|
||||
|
||||
private 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());
|
||||
}
|
||||
|
||||
private NotificationHandler asyncResourcesUpdatedNotificationHandler(
|
||||
List<Function<List<McpSchema.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers) {
|
||||
return params -> {
|
||||
McpSchema.ResourcesUpdatedNotification resourcesUpdatedNotification = transport.unmarshalFrom(params,
|
||||
new TypeRef<>() {
|
||||
});
|
||||
|
||||
return readResource(new McpSchema.ReadResourceRequest(resourcesUpdatedNotification.uri()))
|
||||
.flatMap(readResourceResult -> Flux.fromIterable(resourcesUpdateConsumers)
|
||||
.flatMap(consumer -> consumer.apply(readResourceResult.contents()))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling resource update notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Prompts
|
||||
// --------------------------
|
||||
private static final TypeRef<McpSchema.ListPromptsResult> LIST_PROMPTS_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
private static final TypeRef<McpSchema.GetPromptResult> GET_PROMPT_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the list of all prompts provided by the server.
|
||||
* @return A Mono that completes with the list of all prompts result.
|
||||
* @see McpSchema.ListPromptsResult
|
||||
* @see #getPrompt(GetPromptRequest)
|
||||
*/
|
||||
public Mono<ListPromptsResult> listPrompts() {
|
||||
return this.listPrompts(McpSchema.FIRST_PAGE)
|
||||
.expand(result -> (result.nextCursor() != null) ? this.listPrompts(result.nextCursor()) : Mono.empty())
|
||||
.reduce(new ListPromptsResult(new ArrayList<>(), null), (allPromptsResult, result) -> {
|
||||
allPromptsResult.prompts().addAll(result.prompts());
|
||||
return allPromptsResult;
|
||||
})
|
||||
.map(result -> new McpSchema.ListPromptsResult(Collections.unmodifiableList(result.prompts()), 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.initializer.withIntitialization("listing prompts", init -> init.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.initializer.withIntitialization("getting prompts", init -> init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_PROMPT_GET, getPromptRequest, GET_PROMPT_RESULT_TYPE_REF));
|
||||
}
|
||||
|
||||
private 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 NotificationHandler asyncLoggingNotificationHandler(
|
||||
List<Function<LoggingMessageNotification, Mono<Void>>> loggingConsumers) {
|
||||
|
||||
return params -> {
|
||||
McpSchema.LoggingMessageNotification loggingMessageNotification = transport.unmarshalFrom(params,
|
||||
LOGGING_MESSAGE_NOTIFICATION_TYPE_REF);
|
||||
|
||||
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 IllegalArgumentException("Logging level must not be null"));
|
||||
}
|
||||
|
||||
return this.initializer.withIntitialization("setting logging level", init -> {
|
||||
if (init.initializeResult().capabilities().logging() == null) {
|
||||
return Mono.error(new IllegalStateException("Server's Logging capabilities are not enabled!"));
|
||||
}
|
||||
var params = new McpSchema.SetLevelRequest(loggingLevel);
|
||||
return init.mcpSession().sendRequest(McpSchema.METHOD_LOGGING_SET_LEVEL, params, OBJECT_TYPE_REF).then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a notification handler for progress notifications from the server. This
|
||||
* handler automatically distributes progress notifications to all registered
|
||||
* consumers.
|
||||
* @param progressConsumers List of consumers that will be notified when a progress
|
||||
* message is received. Each consumer receives the progress notification.
|
||||
* @return A NotificationHandler that processes progress notifications by distributing
|
||||
* the message to all registered consumers
|
||||
*/
|
||||
private NotificationHandler asyncProgressNotificationHandler(
|
||||
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers) {
|
||||
|
||||
return params -> {
|
||||
McpSchema.ProgressNotification progressNotification = transport.unmarshalFrom(params,
|
||||
PROGRESS_NOTIFICATION_TYPE_REF);
|
||||
|
||||
return Flux.fromIterable(progressConsumers)
|
||||
.flatMap(consumer -> consumer.apply(progressNotification))
|
||||
.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.initializer.setProtocolVersions(protocolVersions);
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Completions
|
||||
// --------------------------
|
||||
private static final TypeRef<McpSchema.CompleteResult> COMPLETION_COMPLETE_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a completion/complete request to generate value suggestions based on a given
|
||||
* reference and argument. This is typically used to provide auto-completion options
|
||||
* for user input fields.
|
||||
* @param completeRequest The request containing the prompt or resource reference and
|
||||
* argument for which to generate completions.
|
||||
* @return A Mono that completes with the result containing completion suggestions.
|
||||
* @see McpSchema.CompleteRequest
|
||||
* @see McpSchema.CompleteResult
|
||||
*/
|
||||
public Mono<McpSchema.CompleteResult> completeCompletion(McpSchema.CompleteRequest completeRequest) {
|
||||
return this.initializer.withIntitialization("complete completions", init -> init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_COMPLETION_COMPLETE, completeRequest, COMPLETION_COMPLETE_RESULT_TYPE_REF));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,759 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
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;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Implementation;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Root;
|
||||
import io.modelcontextprotocol.spec.McpTransport;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* <li>{@link McpSyncClient} for blocking operations with direct 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 McpSyncClient
|
||||
* @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.ResourceContents>>> resourcesUpdateConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers = new ArrayList<>();
|
||||
|
||||
private final List<Consumer<McpSchema.ProgressNotification>> progressConsumers = new ArrayList<>();
|
||||
|
||||
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
|
||||
|
||||
private Function<ElicitRequest, ElicitResult> elicitationHandler;
|
||||
|
||||
private Supplier<McpTransportContext> contextProvider = () -> McpTransportContext.EMPTY;
|
||||
|
||||
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 initialization
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom elicitation handler for processing elicitation message requests.
|
||||
* The elicitation handler can modify or validate messages before they are sent to
|
||||
* the server, enabling custom processing logic.
|
||||
* @param elicitationHandler A function that processes elicitation requests and
|
||||
* returns results. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if elicitationHandler is null
|
||||
*/
|
||||
public SyncSpec elicitation(Function<ElicitRequest, ElicitResult> elicitationHandler) {
|
||||
Assert.notNull(elicitationHandler, "Elicitation handler must not be null");
|
||||
this.elicitationHandler = elicitationHandler;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified of progress notifications from the server. This
|
||||
* allows the client to track long-running operations and provide feedback to
|
||||
* users.
|
||||
* @param progressConsumer A consumer that receives progress notifications. Must
|
||||
* not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if progressConsumer is null
|
||||
*/
|
||||
public SyncSpec progressConsumer(Consumer<McpSchema.ProgressNotification> progressConsumer) {
|
||||
Assert.notNull(progressConsumer, "Progress consumer must not be null");
|
||||
this.progressConsumers.add(progressConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a multiple consumers to be notified of progress notifications from the
|
||||
* server. This allows the client to track long-running operations and provide
|
||||
* feedback to users.
|
||||
* @param progressConsumers A list of consumers that receives progress
|
||||
* notifications. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if progressConsumer is null
|
||||
*/
|
||||
public SyncSpec progressConsumers(List<Consumer<McpSchema.ProgressNotification>> progressConsumers) {
|
||||
Assert.notNull(progressConsumers, "Progress consumers must not be null");
|
||||
this.progressConsumers.addAll(progressConsumers);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a provider of {@link McpTransportContext}, providing a context before
|
||||
* calling any client operation. This allows to extract thread-locals and hand
|
||||
* them over to the underlying transport.
|
||||
* <p>
|
||||
* There is no direct equivalent in {@link AsyncSpec}. To achieve the same result,
|
||||
* append {@code contextWrite(McpTransportContext.KEY, context)} to any
|
||||
* {@link McpAsyncClient} call.
|
||||
* @param contextProvider A supplier to create a context
|
||||
* @return This builder for method chaining
|
||||
*/
|
||||
public SyncSpec transportContextProvider(Supplier<McpTransportContext> contextProvider) {
|
||||
this.contextProvider = contextProvider;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of {@link McpSyncClient} with the provided configurations or
|
||||
* sensible defaults.
|
||||
* @return a new instance of {@link McpSyncClient}.
|
||||
*/
|
||||
public McpSyncClient build() {
|
||||
McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities,
|
||||
this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
|
||||
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.samplingHandler,
|
||||
this.elicitationHandler);
|
||||
|
||||
McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);
|
||||
|
||||
return new McpSyncClient(
|
||||
new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, asyncFeatures),
|
||||
this.contextProvider);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers = 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 final List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers = new ArrayList<>();
|
||||
|
||||
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
|
||||
|
||||
private Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler;
|
||||
|
||||
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 initialization
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a custom elicitation handler for processing elicitation message requests.
|
||||
* The elicitation handler can modify or validate messages before they are sent to
|
||||
* the server, enabling custom processing logic.
|
||||
* @param elicitationHandler A function that processes elicitation requests and
|
||||
* returns results. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if elicitationHandler is null
|
||||
*/
|
||||
public AsyncSpec elicitation(Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler) {
|
||||
Assert.notNull(elicitationHandler, "Elicitation handler must not be null");
|
||||
this.elicitationHandler = elicitationHandler;
|
||||
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 a specific resource is updated. This allows
|
||||
* the client to react to changes in individual resources, such as updates to
|
||||
* their content or metadata.
|
||||
* @param resourcesUpdateConsumer A consumer function that processes the updated
|
||||
* resource and returns a Mono indicating the completion of the processing. Must
|
||||
* not be null.
|
||||
* @return This builder instance for method chaining.
|
||||
* @throws IllegalArgumentException If the resourcesUpdateConsumer is null.
|
||||
*/
|
||||
public AsyncSpec resourcesUpdateConsumer(
|
||||
Function<List<McpSchema.ResourceContents>, Mono<Void>> resourcesUpdateConsumer) {
|
||||
Assert.notNull(resourcesUpdateConsumer, "Resources update consumer must not be null");
|
||||
this.resourcesUpdateConsumers.add(resourcesUpdateConsumer);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a consumer to be notified of progress notifications from the server. This
|
||||
* allows the client to track long-running operations and provide feedback to
|
||||
* users.
|
||||
* @param progressConsumer A consumer that receives progress notifications. Must
|
||||
* not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if progressConsumer is null
|
||||
*/
|
||||
public AsyncSpec progressConsumer(Function<McpSchema.ProgressNotification, Mono<Void>> progressConsumer) {
|
||||
Assert.notNull(progressConsumer, "Progress consumer must not be null");
|
||||
this.progressConsumers.add(progressConsumer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a multiple consumers to be notified of progress notifications from the
|
||||
* server. This allows the client to track long-running operations and provide
|
||||
* feedback to users.
|
||||
* @param progressConsumers A list of consumers that receives progress
|
||||
* notifications. Must not be null.
|
||||
* @return This builder instance for method chaining
|
||||
* @throws IllegalArgumentException if progressConsumer is null
|
||||
*/
|
||||
public AsyncSpec progressConsumers(
|
||||
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers) {
|
||||
Assert.notNull(progressConsumers, "Progress consumers must not be null");
|
||||
this.progressConsumers.addAll(progressConsumers);
|
||||
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.resourcesUpdateConsumers,
|
||||
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
|
||||
this.samplingHandler, this.elicitationHandler));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.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 progressConsumers the progress consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
* @param elicitationHandler the elicitation 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.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers,
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
|
||||
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
|
||||
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
|
||||
|
||||
/**
|
||||
* 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 progressConsumers the progress consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
* @param elicitationHandler the elicitation 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.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers,
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
|
||||
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
|
||||
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
|
||||
|
||||
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,
|
||||
elicitationHandler != null ? new McpSchema.ClientCapabilities.Elicitation() : 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.resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of();
|
||||
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
|
||||
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
|
||||
this.progressConsumers = progressConsumers != null ? progressConsumers : List.of();
|
||||
this.samplingHandler = samplingHandler;
|
||||
this.elicitationHandler = elicitationHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Only exists for backwards-compatibility purposes.
|
||||
*/
|
||||
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.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers,
|
||||
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
|
||||
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
|
||||
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
|
||||
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
|
||||
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
|
||||
elicitationHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.ResourceContents>, Mono<Void>>> resourcesUpdateConsumers = new ArrayList<>();
|
||||
for (Consumer<List<McpSchema.ResourceContents>> consumer : syncSpec.resourcesUpdateConsumers()) {
|
||||
resourcesUpdateConsumers.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()));
|
||||
}
|
||||
|
||||
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers = new ArrayList<>();
|
||||
for (Consumer<McpSchema.ProgressNotification> consumer : syncSpec.progressConsumers()) {
|
||||
progressConsumers.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());
|
||||
|
||||
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler = r -> Mono
|
||||
.fromCallable(() -> syncSpec.elicitationHandler().apply(r))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
|
||||
return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(),
|
||||
toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers,
|
||||
loggingConsumers, progressConsumers, samplingHandler, elicitationHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 progressConsumers the progress consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
* @param elicitationHandler the elicitation 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.ResourceContents>>> resourcesUpdateConsumers,
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
|
||||
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
|
||||
|
||||
/**
|
||||
* 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 resourcesUpdateConsumers the resource update consumers.
|
||||
* @param promptsChangeConsumers the prompts change consumers.
|
||||
* @param loggingConsumers the logging consumers.
|
||||
* @param progressConsumers the progress consumers.
|
||||
* @param samplingHandler the sampling handler.
|
||||
* @param elicitationHandler the elicitation 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.ResourceContents>>> resourcesUpdateConsumers,
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
|
||||
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
|
||||
|
||||
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,
|
||||
elicitationHandler != null ? new McpSchema.ClientCapabilities.Elicitation() : 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.resourcesUpdateConsumers = resourcesUpdateConsumers != null ? resourcesUpdateConsumers : List.of();
|
||||
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
|
||||
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
|
||||
this.progressConsumers = progressConsumers != null ? progressConsumers : List.of();
|
||||
this.samplingHandler = samplingHandler;
|
||||
this.elicitationHandler = elicitationHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Only exists for backwards-compatibility purposes.
|
||||
*/
|
||||
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.ResourceContents>>> resourcesUpdateConsumers,
|
||||
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
|
||||
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
|
||||
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
|
||||
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
|
||||
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
|
||||
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
|
||||
elicitationHandler);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* A synchronous client implementation for the Model Context Protocol (MCP) that wraps an
|
||||
* {@link McpAsyncClient} to provide blocking operations.
|
||||
*
|
||||
* <p>
|
||||
* This client implements the MCP specification by delegating to an asynchronous client
|
||||
* and blocking on the results. Key features include:
|
||||
* <ul>
|
||||
* <li>Synchronous, blocking API for simpler integration in non-reactive applications
|
||||
* <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
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* The client follows the same lifecycle as its async counterpart:
|
||||
* <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 implements {@link AutoCloseable} for resource cleanup and provides
|
||||
* both immediate and graceful shutdown options. All operations block until completion or
|
||||
* timeout, making it suitable for traditional synchronous programming models.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @author Christian Tzolov
|
||||
* @author Jihoon Kim
|
||||
* @see McpClient
|
||||
* @see McpAsyncClient
|
||||
* @see McpSchema
|
||||
*/
|
||||
public class McpSyncClient implements AutoCloseable {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpSyncClient.class);
|
||||
|
||||
// TODO: Consider providing a client config to set this properly
|
||||
// this is currently a concern only because AutoCloseable is used - perhaps it
|
||||
// is not a requirement?
|
||||
private static final long DEFAULT_CLOSE_TIMEOUT_MS = 10_000L;
|
||||
|
||||
private final McpAsyncClient delegate;
|
||||
|
||||
private final Supplier<McpTransportContext> contextProvider;
|
||||
|
||||
/**
|
||||
* Create a new McpSyncClient with the given delegate.
|
||||
* @param delegate the asynchronous kernel on top of which this synchronous client
|
||||
* provides a blocking API.
|
||||
* @param contextProvider the supplier of context before calling any non-blocking
|
||||
* operation on underlying delegate
|
||||
*/
|
||||
McpSyncClient(McpAsyncClient delegate, Supplier<McpTransportContext> contextProvider) {
|
||||
Assert.notNull(delegate, "The delegate can not be null");
|
||||
Assert.notNull(contextProvider, "The contextProvider can not be null");
|
||||
this.delegate = delegate;
|
||||
this.contextProvider = contextProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current initialization result.
|
||||
* @return the initialization result.
|
||||
*/
|
||||
public McpSchema.InitializeResult getCurrentInitializationResult() {
|
||||
return this.delegate.getCurrentInitializationResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 instructions that provide guidance to the client on how to interact
|
||||
* with this server.
|
||||
* @return The instructions
|
||||
*/
|
||||
public String getServerInstructions() {
|
||||
return this.delegate.getServerInstructions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server implementation information.
|
||||
* @return The server implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getServerInfo() {
|
||||
return this.delegate.getServerInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the client-server connection is initialized.
|
||||
* @return true if the client-server connection is initialized
|
||||
*/
|
||||
public boolean isInitialized() {
|
||||
return this.delegate.isInitialized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client capabilities that define the supported features and functionality.
|
||||
* @return The client capabilities
|
||||
*/
|
||||
public ClientCapabilities getClientCapabilities() {
|
||||
return this.delegate.getClientCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client implementation information.
|
||||
* @return The client implementation details
|
||||
*/
|
||||
public McpSchema.Implementation getClientInfo() {
|
||||
return this.delegate.getClientInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
this.delegate.close();
|
||||
}
|
||||
|
||||
public boolean closeGracefully() {
|
||||
try {
|
||||
this.delegate.closeGracefully().block(Duration.ofMillis(DEFAULT_CLOSE_TIMEOUT_MS));
|
||||
}
|
||||
catch (RuntimeException e) {
|
||||
logger.warn("Client didn't close within timeout of {} ms.", DEFAULT_CLOSE_TIMEOUT_MS, e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
* <ul>
|
||||
* <li>The protocol version the client supports</li>
|
||||
* <li>The client's capabilities</li>
|
||||
* <li>Client implementation information</li>
|
||||
* </ul>
|
||||
*
|
||||
* The server MUST respond with its own capabilities and information:
|
||||
* {@link McpSchema.ServerCapabilities}. <br/>
|
||||
* After successful initialization, the client MUST send an initialized notification
|
||||
* to indicate it is ready to begin normal operations.
|
||||
*
|
||||
* <br/>
|
||||
*
|
||||
* <a href=
|
||||
* "https://github.com/modelcontextprotocol/specification/blob/main/docs/specification/basic/lifecycle.md#initialization">Initialization
|
||||
* Spec</a>
|
||||
* @return the initialize result.
|
||||
*/
|
||||
public McpSchema.InitializeResult initialize() {
|
||||
// TODO: block takes no argument here as we assume the async client is
|
||||
// configured with a requestTimeout at all times
|
||||
return withProvidedContext(this.delegate.initialize()).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a roots/list_changed notification.
|
||||
*/
|
||||
public void rootsListChangedNotification() {
|
||||
withProvidedContext(this.delegate.rootsListChangedNotification()).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a roots dynamically.
|
||||
*/
|
||||
public void addRoot(McpSchema.Root root) {
|
||||
this.delegate.addRoot(root).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a root dynamically.
|
||||
*/
|
||||
public void removeRoot(String rootUri) {
|
||||
this.delegate.removeRoot(rootUri).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a synchronous ping request.
|
||||
* @return
|
||||
*/
|
||||
public Object ping() {
|
||||
return withProvidedContext(this.delegate.ping()).block();
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Tools
|
||||
// --------------------------
|
||||
/**
|
||||
* 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: - name: The name of the tool to call
|
||||
* (must match a tool name from tools/list) - arguments: Arguments that conform to the
|
||||
* tool's input schema
|
||||
* @return The tool execution result containing: - content: List of content items
|
||||
* (text, images, or embedded resources) representing the tool's output - isError:
|
||||
* Boolean indicating if the execution failed (true) or succeeded (false/absent)
|
||||
*/
|
||||
public McpSchema.CallToolResult callTool(McpSchema.CallToolRequest callToolRequest) {
|
||||
return withProvidedContext(this.delegate.callTool(callToolRequest)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all tools provided by the server.
|
||||
* @return The list of all tools result containing: - tools: List of available tools,
|
||||
* each with a name, description, and input schema - nextCursor: Optional cursor for
|
||||
* pagination if more tools are available
|
||||
*/
|
||||
public McpSchema.ListToolsResult listTools() {
|
||||
return withProvidedContext(this.delegate.listTools()).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of tools provided by the server.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return The list of tools result containing: - tools: List of available tools, each
|
||||
* with a name, description, and input schema - nextCursor: Optional cursor for
|
||||
* pagination if more tools are available
|
||||
*/
|
||||
public McpSchema.ListToolsResult listTools(String cursor) {
|
||||
return withProvidedContext(this.delegate.listTools(cursor)).block();
|
||||
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Resources
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* Retrieves the list of all resources provided by the server.
|
||||
* @return The list of all resources result
|
||||
*/
|
||||
public McpSchema.ListResourcesResult listResources() {
|
||||
return withProvidedContext(this.delegate.listResources()).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of resources provided by the server.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return The list of resources result
|
||||
*/
|
||||
public McpSchema.ListResourcesResult listResources(String cursor) {
|
||||
return withProvidedContext(this.delegate.listResources(cursor)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/read request.
|
||||
* @param resource the resource to read
|
||||
* @return the resource content.
|
||||
*/
|
||||
public McpSchema.ReadResourceResult readResource(McpSchema.Resource resource) {
|
||||
return withProvidedContext(this.delegate.readResource(resource)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/read request.
|
||||
* @param readResourceRequest the read resource request.
|
||||
* @return the resource content.
|
||||
*/
|
||||
public McpSchema.ReadResourceResult readResource(McpSchema.ReadResourceRequest readResourceRequest) {
|
||||
return withProvidedContext(this.delegate.readResource(readResourceRequest)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of all resource templates provided by the server.
|
||||
* @return The list of all resource templates result.
|
||||
*/
|
||||
public McpSchema.ListResourceTemplatesResult listResourceTemplates() {
|
||||
return withProvidedContext(this.delegate.listResourceTemplates()).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource templates allow servers to expose parameterized resources using URI
|
||||
* templates. Arguments may be auto-completed through the completion API.
|
||||
*
|
||||
* Retrieves a paginated list of resource templates provided by the server.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return The list of resource templates result.
|
||||
*/
|
||||
public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor) {
|
||||
return withProvidedContext(this.delegate.listResourceTemplates(cursor)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions. The protocol supports optional subscriptions to resource changes.
|
||||
* Clients can subscribe to specific resources and receive notifications when they
|
||||
* change.
|
||||
*
|
||||
* Send a resources/subscribe request.
|
||||
* @param subscribeRequest the subscribe request contains the uri of the resource to
|
||||
* subscribe to.
|
||||
*/
|
||||
public void subscribeResource(McpSchema.SubscribeRequest subscribeRequest) {
|
||||
withProvidedContext(this.delegate.subscribeResource(subscribeRequest)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a resources/unsubscribe request.
|
||||
* @param unsubscribeRequest the unsubscribe request contains the uri of the resource
|
||||
* to unsubscribe from.
|
||||
*/
|
||||
public void unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRequest) {
|
||||
withProvidedContext(this.delegate.unsubscribeResource(unsubscribeRequest)).block();
|
||||
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Prompts
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* Retrieves the list of all prompts provided by the server.
|
||||
* @return The list of all prompts result.
|
||||
*/
|
||||
public ListPromptsResult listPrompts() {
|
||||
return withProvidedContext(this.delegate.listPrompts()).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a paginated list of prompts provided by the server.
|
||||
* @param cursor Optional pagination cursor from a previous list request
|
||||
* @return The list of prompts result.
|
||||
*/
|
||||
public ListPromptsResult listPrompts(String cursor) {
|
||||
return withProvidedContext(this.delegate.listPrompts(cursor)).block();
|
||||
|
||||
}
|
||||
|
||||
public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) {
|
||||
return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block();
|
||||
}
|
||||
|
||||
/**
|
||||
* Client can set the minimum logging level it wants to receive from the server.
|
||||
* @param loggingLevel the min logging level
|
||||
*/
|
||||
public void setLoggingLevel(McpSchema.LoggingLevel loggingLevel) {
|
||||
withProvidedContext(this.delegate.setLoggingLevel(loggingLevel)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a completion/complete request.
|
||||
* @param completeRequest the completion request contains the prompt or resource
|
||||
* reference and arguments for generating suggestions.
|
||||
* @return the completion result containing suggested values.
|
||||
*/
|
||||
public McpSchema.CompleteResult completeCompletion(McpSchema.CompleteRequest completeRequest) {
|
||||
return withProvidedContext(this.delegate.completeCompletion(completeRequest)).block();
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given action, on assembly, capture the "context" via the
|
||||
* {@link #contextProvider} and store it in the Reactor context.
|
||||
* @param action the action to perform
|
||||
* @return the result of the action
|
||||
*/
|
||||
private <T> Mono<T> withProvidedContext(Mono<T> action) {
|
||||
return action.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, this.contextProvider.get()));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,514 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
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.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
|
||||
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
|
||||
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
/**
|
||||
* 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 String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2024_11_05;
|
||||
|
||||
private static final String MCP_PROTOCOL_VERSION_HEADER_NAME = "MCP-Protocol-Version";
|
||||
|
||||
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 URI baseUri;
|
||||
|
||||
/** SSE endpoint path */
|
||||
private final String sseEndpoint;
|
||||
|
||||
/**
|
||||
* 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 mapper for message serialization/deserialization */
|
||||
protected McpJsonMapper jsonMapper;
|
||||
|
||||
/** Flag indicating if the transport is in closing state */
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
/** Holds the SSE subscription disposable */
|
||||
private final AtomicReference<Disposable> sseSubscription = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* Sink for managing the message endpoint URI provided by the server. Stores the most
|
||||
* recent endpoint URI and makes it available for outbound message processing.
|
||||
*/
|
||||
protected final Sinks.One<String> messageEndpointSink = Sinks.one();
|
||||
|
||||
/**
|
||||
* Customizer to modify requests before they are executed.
|
||||
*/
|
||||
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
|
||||
|
||||
/**
|
||||
* 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 jsonMapper the object mapper for JSON serialization/deserialization
|
||||
* @param httpRequestCustomizer customizer for the requestBuilder before executing
|
||||
* requests
|
||||
* @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null
|
||||
*/
|
||||
HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri,
|
||||
String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) {
|
||||
Assert.notNull(jsonMapper, "jsonMapper 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");
|
||||
Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null");
|
||||
this.baseUri = URI.create(baseUri);
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.httpClient = httpClient;
|
||||
this.requestBuilder = requestBuilder;
|
||||
this.httpRequestCustomizer = httpRequestCustomizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
|
||||
|
||||
private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.NOOP;
|
||||
|
||||
private Duration connectTimeout = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 JSON mapper implementation to use for serialization/deserialization.
|
||||
* @param jsonMapper the JSON mapper
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "jsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||
* executing them.
|
||||
* <p>
|
||||
* This overrides the customizer from
|
||||
* {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)}.
|
||||
* <p>
|
||||
* Do NOT use a blocking {@link McpSyncHttpClientRequestCustomizer} in a
|
||||
* non-blocking context. Use
|
||||
* {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)}
|
||||
* instead.
|
||||
* @param syncHttpRequestCustomizer the request customizer
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder httpRequestCustomizer(McpSyncHttpClientRequestCustomizer syncHttpRequestCustomizer) {
|
||||
this.httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.fromSync(syncHttpRequestCustomizer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||
* executing them.
|
||||
* <p>
|
||||
* This overrides the customizer from
|
||||
* {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}.
|
||||
* <p>
|
||||
* Do NOT use a blocking implementation in a non-blocking context.
|
||||
* @param asyncHttpRequestCustomizer the request customizer
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer asyncHttpRequestCustomizer) {
|
||||
this.httpRequestCustomizer = asyncHttpRequestCustomizer;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the connection timeout for the HTTP client.
|
||||
* @param connectTimeout the connection timeout duration
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder connectTimeout(Duration connectTimeout) {
|
||||
Assert.notNull(connectTimeout, "connectTimeout must not be null");
|
||||
this.connectTimeout = connectTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new {@link HttpClientSseClientTransport} instance.
|
||||
* @return a new transport instance
|
||||
*/
|
||||
public HttpClientSseClientTransport build() {
|
||||
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
|
||||
return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint,
|
||||
jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper, httpRequestCustomizer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||
var uri = Utils.resolveUri(this.baseUri, this.sseEndpoint);
|
||||
|
||||
return Mono.deferContextual(ctx -> {
|
||||
var builder = requestBuilder.copy()
|
||||
.uri(uri)
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
|
||||
.GET();
|
||||
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
|
||||
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
|
||||
}).flatMap(requestBuilder -> Mono.create(sink -> {
|
||||
Disposable connection = Flux.<ResponseEvent>create(sseSink -> this.httpClient
|
||||
.sendAsync(requestBuilder.build(),
|
||||
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, sseSink))
|
||||
.exceptionallyCompose(e -> {
|
||||
sseSink.error(e);
|
||||
return CompletableFuture.failedFuture(e);
|
||||
}))
|
||||
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
|
||||
.flatMap(responseEvent -> {
|
||||
if (isClosing) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
int statusCode = responseEvent.responseInfo().statusCode();
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
try {
|
||||
if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
|
||||
String messageEndpointUri = responseEvent.sseEvent().data();
|
||||
if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) {
|
||||
sink.success();
|
||||
return Flux.empty(); // No further processing needed
|
||||
}
|
||||
else {
|
||||
sink.error(new RuntimeException("Failed to handle SSE endpoint event"));
|
||||
}
|
||||
}
|
||||
else if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
|
||||
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper,
|
||||
responseEvent.sseEvent().data());
|
||||
sink.success();
|
||||
return Flux.just(message);
|
||||
}
|
||||
else {
|
||||
logger.debug("Received unrecognized SSE event type: {}", responseEvent.sseEvent());
|
||||
sink.success();
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
sink.error(new McpTransportException("Error processing SSE event", e));
|
||||
}
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new RuntimeException("Failed to send message: " + responseEvent));
|
||||
|
||||
})
|
||||
.flatMap(jsonRpcMessage -> handler.apply(Mono.just(jsonRpcMessage)))
|
||||
.onErrorComplete(t -> {
|
||||
if (!isClosing) {
|
||||
logger.warn("SSE stream observed an error", t);
|
||||
sink.error(t);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.doFinally(s -> {
|
||||
Disposable ref = this.sseSubscription.getAndSet(null);
|
||||
if (ref != null && !ref.isDisposed()) {
|
||||
ref.dispose();
|
||||
}
|
||||
})
|
||||
.contextWrite(sink.contextView())
|
||||
.subscribe();
|
||||
|
||||
this.sseSubscription.set(connection);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
return this.messageEndpointSink.asMono().flatMap(messageEndpointUri -> {
|
||||
if (isClosing) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return this.serializeMessage(message)
|
||||
.flatMap(body -> sendHttpPost(messageEndpointUri, body).handle((response, sink) -> {
|
||||
if (response.statusCode() != 200 && response.statusCode() != 201 && response.statusCode() != 202
|
||||
&& response.statusCode() != 206) {
|
||||
sink.error(new RuntimeException("Sending message failed with a non-OK HTTP code: "
|
||||
+ response.statusCode() + " - " + response.body()));
|
||||
}
|
||||
else {
|
||||
sink.next(response);
|
||||
sink.complete();
|
||||
}
|
||||
}))
|
||||
.doOnError(error -> {
|
||||
if (!isClosing) {
|
||||
logger.error("Error sending message: {}", error.getMessage());
|
||||
}
|
||||
});
|
||||
}).then();
|
||||
|
||||
}
|
||||
|
||||
private Mono<String> serializeMessage(final JSONRPCMessage message) {
|
||||
return Mono.defer(() -> {
|
||||
try {
|
||||
return Mono.just(jsonMapper.writeValueAsString(message));
|
||||
}
|
||||
catch (IOException e) {
|
||||
return Mono.error(new McpTransportException("Failed to serialize message", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<HttpResponse<String>> sendHttpPost(final String endpoint, final String body) {
|
||||
final URI requestUri = Utils.resolveUri(baseUri, endpoint);
|
||||
return Mono.deferContextual(ctx -> {
|
||||
var builder = this.requestBuilder.copy()
|
||||
.uri(requestUri)
|
||||
.header("Content-Type", "application/json")
|
||||
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
|
||||
return Mono.from(this.httpRequestCustomizer.customize(builder, "POST", requestUri, body, transportContext));
|
||||
}).flatMap(customizedBuilder -> {
|
||||
var request = customizedBuilder.build();
|
||||
return Mono.fromFuture(httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the transport connection.
|
||||
*
|
||||
* <p>
|
||||
* Sets the closing flag and disposes of the SSE subscription. 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;
|
||||
Disposable subscription = sseSubscription.get();
|
||||
if (subscription != null && !subscription.isDisposed()) {
|
||||
subscription.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, TypeRef<T> typeRef) {
|
||||
return this.jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,774 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
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.net.http.HttpResponse.BodyHandler;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer;
|
||||
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
|
||||
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
|
||||
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.McpTransportSession;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.spec.McpTransportStream;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.function.Tuple2;
|
||||
import reactor.util.function.Tuples;
|
||||
|
||||
/**
|
||||
* An implementation of the Streamable HTTP protocol as defined by the
|
||||
* <code>2025-03-26</code> version of the MCP specification.
|
||||
*
|
||||
* <p>
|
||||
* The transport is capable of resumability and reconnects. It reacts to transport-level
|
||||
* session invalidation and will propagate {@link McpTransportSessionNotFoundException
|
||||
* appropriate exceptions} to the higher level abstraction layer when needed in order to
|
||||
* allow proper state management. The implementation handles servers that are stateful and
|
||||
* provide session meta information, but can also communicate with stateless servers that
|
||||
* do not provide a session identifier and do not support SSE streams.
|
||||
* </p>
|
||||
* <p>
|
||||
* This implementation does not handle backwards compatibility with the <a href=
|
||||
* "https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">"HTTP
|
||||
* with SSE" transport</a>. In order to communicate over the phased-out
|
||||
* <code>2024-11-05</code> protocol, use {@link HttpClientSseClientTransport} or
|
||||
* {@code WebFluxSseClientTransport}.
|
||||
* </p>
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @see <a href=
|
||||
* "https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http">Streamable
|
||||
* HTTP transport specification</a>
|
||||
*/
|
||||
public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(HttpClientStreamableHttpTransport.class);
|
||||
|
||||
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_03_26;
|
||||
|
||||
private static final String DEFAULT_ENDPOINT = "/mcp";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages received through the SSE connection. The server
|
||||
* sends messages with this event type to transmit JSON-RPC protocol data.
|
||||
*/
|
||||
private static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
private static final String APPLICATION_JSON = "application/json";
|
||||
|
||||
private static final String TEXT_EVENT_STREAM = "text/event-stream";
|
||||
|
||||
public static int NOT_FOUND = 404;
|
||||
|
||||
public static int METHOD_NOT_ALLOWED = 405;
|
||||
|
||||
public static int BAD_REQUEST = 400;
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
private final URI baseUri;
|
||||
|
||||
private final String endpoint;
|
||||
|
||||
private final boolean openConnectionOnStartup;
|
||||
|
||||
private final boolean resumableStreams;
|
||||
|
||||
private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer;
|
||||
|
||||
private final AtomicReference<DefaultMcpTransportSession> activeSession = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>>> handler = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<Consumer<Throwable>> exceptionHandler = new AtomicReference<>();
|
||||
|
||||
private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient,
|
||||
HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams,
|
||||
boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) {
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.httpClient = httpClient;
|
||||
this.requestBuilder = requestBuilder;
|
||||
this.baseUri = URI.create(baseUri);
|
||||
this.endpoint = endpoint;
|
||||
this.resumableStreams = resumableStreams;
|
||||
this.openConnectionOnStartup = openConnectionOnStartup;
|
||||
this.activeSession.set(createTransportSession());
|
||||
this.httpRequestCustomizer = httpRequestCustomizer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26);
|
||||
}
|
||||
|
||||
public static Builder builder(String baseUri) {
|
||||
return new Builder(baseUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
|
||||
return Mono.deferContextual(ctx -> {
|
||||
this.handler.set(handler);
|
||||
if (this.openConnectionOnStartup) {
|
||||
logger.debug("Eagerly opening connection on startup");
|
||||
return this.reconnect(null).onErrorComplete(t -> {
|
||||
logger.warn("Eager connect failed ", t);
|
||||
return true;
|
||||
}).then();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private DefaultMcpTransportSession createTransportSession() {
|
||||
Function<String, Publisher<Void>> onClose = sessionId -> sessionId == null ? Mono.empty()
|
||||
: createDelete(sessionId);
|
||||
return new DefaultMcpTransportSession(onClose);
|
||||
}
|
||||
|
||||
private Publisher<Void> createDelete(String sessionId) {
|
||||
|
||||
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
|
||||
return Mono.deferContextual(ctx -> {
|
||||
var builder = this.requestBuilder.copy()
|
||||
.uri(uri)
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
|
||||
.DELETE();
|
||||
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
|
||||
return Mono.from(this.httpRequestCustomizer.customize(builder, "DELETE", uri, null, transportContext));
|
||||
}).flatMap(requestBuilder -> {
|
||||
var request = requestBuilder.build();
|
||||
return Mono.fromFuture(() -> this.httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()));
|
||||
}).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setExceptionHandler(Consumer<Throwable> handler) {
|
||||
logger.debug("Exception handler registered");
|
||||
this.exceptionHandler.set(handler);
|
||||
}
|
||||
|
||||
private void handleException(Throwable t) {
|
||||
logger.debug("Handling exception for session {}", sessionIdOrPlaceholder(this.activeSession.get()), t);
|
||||
if (t instanceof McpTransportSessionNotFoundException) {
|
||||
McpTransportSession<?> invalidSession = this.activeSession.getAndSet(createTransportSession());
|
||||
logger.warn("Server does not recognize session {}. Invalidating.", invalidSession.sessionId());
|
||||
invalidSession.close();
|
||||
}
|
||||
Consumer<Throwable> handler = this.exceptionHandler.get();
|
||||
if (handler != null) {
|
||||
handler.accept(t);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
logger.debug("Graceful close triggered");
|
||||
DefaultMcpTransportSession currentSession = this.activeSession.getAndSet(createTransportSession());
|
||||
if (currentSession != null) {
|
||||
return currentSession.closeGracefully();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Disposable> reconnect(McpTransportStream<Disposable> stream) {
|
||||
|
||||
return Mono.deferContextual(ctx -> {
|
||||
|
||||
if (stream != null) {
|
||||
logger.debug("Reconnecting stream {} with lastId {}", stream.streamId(), stream.lastId());
|
||||
}
|
||||
else {
|
||||
logger.debug("Reconnecting with no prior stream");
|
||||
}
|
||||
|
||||
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
||||
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
||||
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
|
||||
|
||||
Disposable connection = Mono.deferContextual(connectionCtx -> {
|
||||
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
|
||||
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
requestBuilder = requestBuilder.header(HttpHeaders.MCP_SESSION_ID,
|
||||
transportSession.sessionId().get());
|
||||
}
|
||||
|
||||
if (stream != null && stream.lastId().isPresent()) {
|
||||
requestBuilder = requestBuilder.header(HttpHeaders.LAST_EVENT_ID, stream.lastId().get());
|
||||
}
|
||||
|
||||
var builder = requestBuilder.uri(uri)
|
||||
.header("Accept", TEXT_EVENT_STREAM)
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
|
||||
.GET();
|
||||
var transportContext = connectionCtx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
|
||||
return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext));
|
||||
})
|
||||
.flatMapMany(
|
||||
requestBuilder -> Flux.<ResponseEvent>create(
|
||||
sseSink -> this.httpClient
|
||||
.sendAsync(requestBuilder.build(),
|
||||
responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo,
|
||||
sseSink))
|
||||
.whenComplete((response, throwable) -> {
|
||||
if (throwable != null) {
|
||||
sseSink.error(throwable);
|
||||
}
|
||||
else {
|
||||
logger.debug("SSE connection established successfully");
|
||||
}
|
||||
}))
|
||||
.map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent)
|
||||
.flatMap(responseEvent -> {
|
||||
int statusCode = responseEvent.responseInfo().statusCode();
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
|
||||
if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
|
||||
try {
|
||||
// We don't support batching ATM and probably
|
||||
// won't since the next version considers
|
||||
// removing it.
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(
|
||||
this.jsonMapper, responseEvent.sseEvent().data());
|
||||
|
||||
Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> idWithMessages = Tuples
|
||||
.of(Optional.ofNullable(responseEvent.sseEvent().id()),
|
||||
List.of(message));
|
||||
|
||||
McpTransportStream<Disposable> sessionStream = stream != null ? stream
|
||||
: new DefaultMcpTransportStream<>(this.resumableStreams,
|
||||
this::reconnect);
|
||||
logger.debug("Connected stream {}", sessionStream.streamId());
|
||||
|
||||
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
||||
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Error parsing JSON-RPC message: " + responseEvent, ioException));
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.debug("Received SSE event with type: {}", responseEvent.sseEvent());
|
||||
return Flux.empty();
|
||||
}
|
||||
}
|
||||
else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
|
||||
logger
|
||||
.debug("The server does not support SSE streams, using request-response mode.");
|
||||
return Flux.empty();
|
||||
}
|
||||
else if (statusCode == NOT_FOUND) {
|
||||
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id
|
||||
// and the response is 404, we consider it a
|
||||
// session not found error.
|
||||
logger.debug("Session not found for session ID: {}",
|
||||
transportSession.sessionId().get());
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionIdRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpTransportException("Server Not Found. Status code:" + statusCode
|
||||
+ ", response-event:" + responseEvent));
|
||||
}
|
||||
else if (statusCode == BAD_REQUEST) {
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id
|
||||
// and thre response is 404, we consider it a
|
||||
// session not found error.
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionIdRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpTransportException("Bad Request. Status code:" + statusCode
|
||||
+ ", response-event:" + responseEvent));
|
||||
|
||||
}
|
||||
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Received unrecognized SSE event type: " + responseEvent.sseEvent().event()));
|
||||
}).<McpSchema
|
||||
.JSONRPCMessage>flatMap(
|
||||
jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage)))
|
||||
.onErrorMap(CompletionException.class, t -> t.getCause())
|
||||
.onErrorComplete(t -> {
|
||||
this.handleException(t);
|
||||
return true;
|
||||
})
|
||||
.doFinally(s -> {
|
||||
Disposable ref = disposableRef.getAndSet(null);
|
||||
if (ref != null) {
|
||||
transportSession.removeConnection(ref);
|
||||
}
|
||||
}))
|
||||
.contextWrite(ctx)
|
||||
.subscribe();
|
||||
|
||||
disposableRef.set(connection);
|
||||
transportSession.addConnection(connection);
|
||||
return Mono.just(connection);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private BodyHandler<Void> toSendMessageBodySubscriber(FluxSink<ResponseEvent> sink) {
|
||||
|
||||
BodyHandler<Void> responseBodyHandler = responseInfo -> {
|
||||
|
||||
String contentType = responseInfo.headers().firstValue("Content-Type").orElse("").toLowerCase();
|
||||
|
||||
if (contentType.contains(TEXT_EVENT_STREAM)) {
|
||||
// For SSE streams, use line subscriber that returns Void
|
||||
logger.debug("Received SSE stream response, using line subscriber");
|
||||
return ResponseSubscribers.sseToBodySubscriber(responseInfo, sink);
|
||||
}
|
||||
else if (contentType.contains(APPLICATION_JSON)) {
|
||||
// For JSON responses and others, use string subscriber
|
||||
logger.debug("Received response, using string subscriber");
|
||||
return ResponseSubscribers.aggregateBodySubscriber(responseInfo, sink);
|
||||
}
|
||||
|
||||
logger.debug("Received Bodyless response, using discarding subscriber");
|
||||
return ResponseSubscribers.bodilessBodySubscriber(responseInfo, sink);
|
||||
};
|
||||
|
||||
return responseBodyHandler;
|
||||
|
||||
}
|
||||
|
||||
public String toString(McpSchema.JSONRPCMessage message) {
|
||||
try {
|
||||
return this.jsonMapper.writeValueAsString(message);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException("Failed to serialize JSON-RPC message", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage sentMessage) {
|
||||
return Mono.create(deliveredSink -> {
|
||||
logger.debug("Sending message {}", sentMessage);
|
||||
|
||||
final AtomicReference<Disposable> disposableRef = new AtomicReference<>();
|
||||
final McpTransportSession<Disposable> transportSession = this.activeSession.get();
|
||||
|
||||
var uri = Utils.resolveUri(this.baseUri, this.endpoint);
|
||||
String jsonBody = this.toString(sentMessage);
|
||||
|
||||
Disposable connection = Mono.deferContextual(ctx -> {
|
||||
HttpRequest.Builder requestBuilder = this.requestBuilder.copy();
|
||||
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
requestBuilder = requestBuilder.header(HttpHeaders.MCP_SESSION_ID,
|
||||
transportSession.sessionId().get());
|
||||
}
|
||||
|
||||
var builder = requestBuilder.uri(uri)
|
||||
.header("Accept", APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
|
||||
.header("Content-Type", APPLICATION_JSON)
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
||||
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
|
||||
return Mono
|
||||
.from(this.httpRequestCustomizer.customize(builder, "POST", uri, jsonBody, transportContext));
|
||||
}).flatMapMany(requestBuilder -> Flux.<ResponseEvent>create(responseEventSink -> {
|
||||
|
||||
// Create the async request with proper body subscriber selection
|
||||
Mono.fromFuture(this.httpClient
|
||||
.sendAsync(requestBuilder.build(), this.toSendMessageBodySubscriber(responseEventSink))
|
||||
.whenComplete((response, throwable) -> {
|
||||
if (throwable != null) {
|
||||
responseEventSink.error(throwable);
|
||||
}
|
||||
else {
|
||||
logger.debug("SSE connection established successfully");
|
||||
}
|
||||
})).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe();
|
||||
|
||||
})).flatMap(responseEvent -> {
|
||||
if (transportSession.markInitialized(
|
||||
responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) {
|
||||
// Once we have a session, we try to open an async stream for
|
||||
// the server to send notifications and requests out-of-band.
|
||||
|
||||
reconnect(null).contextWrite(deliveredSink.contextView()).subscribe();
|
||||
}
|
||||
|
||||
String sessionRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
|
||||
int statusCode = responseEvent.responseInfo().statusCode();
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
|
||||
String contentType = responseEvent.responseInfo()
|
||||
.headers()
|
||||
.firstValue("Content-Type")
|
||||
.orElse("")
|
||||
.toLowerCase();
|
||||
|
||||
if (contentType.isBlank()) {
|
||||
logger.debug("No content type returned for POST in session {}", sessionRepresentation);
|
||||
// No content type means no response body, so we can just
|
||||
// return
|
||||
// an empty stream
|
||||
deliveredSink.success();
|
||||
return Flux.empty();
|
||||
}
|
||||
else if (contentType.contains(TEXT_EVENT_STREAM)) {
|
||||
return Flux.just(((ResponseSubscribers.SseResponseEvent) responseEvent).sseEvent())
|
||||
.flatMap(sseEvent -> {
|
||||
try {
|
||||
// We don't support batching ATM and probably
|
||||
// won't
|
||||
// since the
|
||||
// next version considers removing it.
|
||||
McpSchema.JSONRPCMessage message = McpSchema
|
||||
.deserializeJsonRpcMessage(this.jsonMapper, sseEvent.data());
|
||||
|
||||
Tuple2<Optional<String>, Iterable<McpSchema.JSONRPCMessage>> idWithMessages = Tuples
|
||||
.of(Optional.ofNullable(sseEvent.id()), List.of(message));
|
||||
|
||||
McpTransportStream<Disposable> sessionStream = new DefaultMcpTransportStream<>(
|
||||
this.resumableStreams, this::reconnect);
|
||||
|
||||
logger.debug("Connected stream {}", sessionStream.streamId());
|
||||
|
||||
deliveredSink.success();
|
||||
|
||||
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Error parsing JSON-RPC message: " + responseEvent, ioException));
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (contentType.contains(APPLICATION_JSON)) {
|
||||
deliveredSink.success();
|
||||
String data = ((ResponseSubscribers.AggregateResponseEvent) responseEvent).data();
|
||||
if (sentMessage instanceof McpSchema.JSONRPCNotification && Utils.hasText(data)) {
|
||||
logger.warn("Notification: {} received non-compliant response: {}", sentMessage, data);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
return Mono.just(McpSchema.deserializeJsonRpcMessage(jsonMapper, data));
|
||||
}
|
||||
catch (IOException e) {
|
||||
return Mono.error(new McpTransportException(
|
||||
"Error deserializing JSON-RPC message: " + responseEvent, e));
|
||||
}
|
||||
}
|
||||
logger.warn("Unknown media type {} returned for POST in session {}", contentType,
|
||||
sessionRepresentation);
|
||||
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new RuntimeException("Unknown media type returned: " + contentType));
|
||||
}
|
||||
else if (statusCode == NOT_FOUND) {
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id and the
|
||||
// response is 404, we consider it a session not found error.
|
||||
logger.debug("Session not found for session ID: {}", transportSession.sessionId().get());
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Server Not Found. Status code:" + statusCode + ", response-event:" + responseEvent));
|
||||
}
|
||||
else if (statusCode == BAD_REQUEST) {
|
||||
// Some implementations can return 400 when presented with a
|
||||
// session id that it doesn't know about, so we will
|
||||
// invalidate the session
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
|
||||
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id and the
|
||||
// response is 404, we consider it a session not found error.
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
|
||||
}
|
||||
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new RuntimeException("Failed to send message: " + responseEvent));
|
||||
})
|
||||
.flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage)))
|
||||
.onErrorMap(CompletionException.class, t -> t.getCause())
|
||||
.onErrorComplete(t -> {
|
||||
// handle the error first
|
||||
this.handleException(t);
|
||||
// inform the caller of sendMessage
|
||||
deliveredSink.error(t);
|
||||
return true;
|
||||
})
|
||||
.doFinally(s -> {
|
||||
logger.debug("SendMessage finally: {}", s);
|
||||
Disposable ref = disposableRef.getAndSet(null);
|
||||
if (ref != null) {
|
||||
transportSession.removeConnection(ref);
|
||||
}
|
||||
})
|
||||
.contextWrite(deliveredSink.contextView())
|
||||
.subscribe();
|
||||
|
||||
disposableRef.set(connection);
|
||||
transportSession.addConnection(connection);
|
||||
});
|
||||
}
|
||||
|
||||
private static String sessionIdOrPlaceholder(McpTransportSession<?> transportSession) {
|
||||
return transportSession.sessionId().orElse("[missing_session_id]");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return this.jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for {@link HttpClientStreamableHttpTransport}.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private final String baseUri;
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
private HttpClient.Builder clientBuilder = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1);
|
||||
|
||||
private String endpoint = DEFAULT_ENDPOINT;
|
||||
|
||||
private boolean resumableStreams = true;
|
||||
|
||||
private boolean openConnectionOnStartup = false;
|
||||
|
||||
private HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
|
||||
|
||||
private McpAsyncHttpClientRequestCustomizer httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.NOOP;
|
||||
|
||||
private Duration connectTimeout = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* Creates a new builder with the specified base URI.
|
||||
* @param baseUri the base URI of the MCP server
|
||||
*/
|
||||
private Builder(String baseUri) {
|
||||
Assert.hasText(baseUri, "baseUri must not be empty");
|
||||
this.baseUri = baseUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a custom {@link McpJsonMapper} implementation to use.
|
||||
* @param jsonMapper instance to use
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder jsonMapper(McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(jsonMapper, "jsonMapper must not be null");
|
||||
this.jsonMapper = jsonMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the endpoint to make HTTP requests against.
|
||||
* @param endpoint endpoint to use
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder endpoint(String endpoint) {
|
||||
Assert.hasText(endpoint, "endpoint must be a non-empty String");
|
||||
this.endpoint = endpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure whether to use the stream resumability feature by keeping track of
|
||||
* SSE event ids.
|
||||
* @param resumableStreams if {@code true} event ids will be tracked and upon
|
||||
* disconnection, the last seen id will be used upon reconnection as a header to
|
||||
* resume consuming messages.
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder resumableStreams(boolean resumableStreams) {
|
||||
this.resumableStreams = resumableStreams;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure whether the client should open an SSE connection upon startup. Not
|
||||
* all servers support this (although it is in theory possible with the current
|
||||
* specification), so use with caution. By default, this value is {@code false}.
|
||||
* @param openConnectionOnStartup if {@code true} the {@link #connect(Function)}
|
||||
* method call will try to open an SSE connection before sending any JSON-RPC
|
||||
* request
|
||||
* @return the builder instance
|
||||
*/
|
||||
public Builder openConnectionOnStartup(boolean openConnectionOnStartup) {
|
||||
this.openConnectionOnStartup = openConnectionOnStartup;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||
* executing them.
|
||||
* <p>
|
||||
* This overrides the customizer from
|
||||
* {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)}.
|
||||
* <p>
|
||||
* Do NOT use a blocking {@link McpSyncHttpClientRequestCustomizer} in a
|
||||
* non-blocking context. Use
|
||||
* {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)}
|
||||
* instead.
|
||||
* @param syncHttpRequestCustomizer the request customizer
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder httpRequestCustomizer(McpSyncHttpClientRequestCustomizer syncHttpRequestCustomizer) {
|
||||
this.httpRequestCustomizer = McpAsyncHttpClientRequestCustomizer.fromSync(syncHttpRequestCustomizer);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the customizer for {@link HttpRequest.Builder}, to modify requests before
|
||||
* executing them.
|
||||
* <p>
|
||||
* This overrides the customizer from
|
||||
* {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}.
|
||||
* <p>
|
||||
* Do NOT use a blocking implementation in a non-blocking context.
|
||||
* @param asyncHttpRequestCustomizer the request customizer
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer asyncHttpRequestCustomizer) {
|
||||
this.httpRequestCustomizer = asyncHttpRequestCustomizer;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the connection timeout for the HTTP client.
|
||||
* @param connectTimeout the connection timeout duration
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder connectTimeout(Duration connectTimeout) {
|
||||
Assert.notNull(connectTimeout, "connectTimeout must not be null");
|
||||
this.connectTimeout = connectTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a fresh instance of {@link HttpClientStreamableHttpTransport} using
|
||||
* the current builder configuration.
|
||||
* @return a new instance of {@link HttpClientStreamableHttpTransport}
|
||||
*/
|
||||
public HttpClientStreamableHttpTransport build() {
|
||||
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
|
||||
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonMapper.getDefault() : jsonMapper,
|
||||
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
|
||||
httpRequestCustomizer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.net.http.HttpResponse;
|
||||
import java.net.http.HttpResponse.BodySubscriber;
|
||||
import java.net.http.HttpResponse.ResponseInfo;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.reactivestreams.FlowAdapters;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import reactor.core.publisher.BaseSubscriber;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
|
||||
/**
|
||||
* Utility class providing various {@link BodySubscriber} implementations for handling
|
||||
* different types of HTTP response bodies in the context of Model Context Protocol (MCP)
|
||||
* clients.
|
||||
*
|
||||
* <p>
|
||||
* Defines subscribers for processing Server-Sent Events (SSE), aggregate responses, and
|
||||
* bodiless responses.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
class ResponseSubscribers {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ResponseSubscribers.class);
|
||||
|
||||
record SseEvent(String id, String event, String data) {
|
||||
}
|
||||
|
||||
sealed interface ResponseEvent permits SseResponseEvent, AggregateResponseEvent, DummyEvent {
|
||||
|
||||
ResponseInfo responseInfo();
|
||||
|
||||
}
|
||||
|
||||
record DummyEvent(ResponseInfo responseInfo) implements ResponseEvent {
|
||||
|
||||
}
|
||||
|
||||
record SseResponseEvent(ResponseInfo responseInfo, SseEvent sseEvent) implements ResponseEvent {
|
||||
}
|
||||
|
||||
record AggregateResponseEvent(ResponseInfo responseInfo, String data) implements ResponseEvent {
|
||||
}
|
||||
|
||||
static BodySubscriber<Void> sseToBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
|
||||
return HttpResponse.BodySubscribers
|
||||
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new SseLineSubscriber(responseInfo, sink)));
|
||||
}
|
||||
|
||||
static BodySubscriber<Void> aggregateBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
|
||||
return HttpResponse.BodySubscribers
|
||||
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new AggregateSubscriber(responseInfo, sink)));
|
||||
}
|
||||
|
||||
static BodySubscriber<Void> bodilessBodySubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
|
||||
return HttpResponse.BodySubscribers
|
||||
.fromLineSubscriber(FlowAdapters.toFlowSubscriber(new BodilessResponseLineSubscriber(responseInfo, sink)));
|
||||
}
|
||||
|
||||
static class SseLineSubscriber extends BaseSubscriber<String> {
|
||||
|
||||
/**
|
||||
* Pattern to extract data content from SSE "data:" lines.
|
||||
*/
|
||||
private static final Pattern EVENT_DATA_PATTERN = Pattern.compile("^data:(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Pattern to extract event ID from SSE "id:" lines.
|
||||
*/
|
||||
private static final Pattern EVENT_ID_PATTERN = Pattern.compile("^id:(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* Pattern to extract event type from SSE "event:" lines.
|
||||
*/
|
||||
private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^event:(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* The sink for emitting parsed response events.
|
||||
*/
|
||||
private final FluxSink<ResponseEvent> sink;
|
||||
|
||||
/**
|
||||
* StringBuilder for accumulating multi-line event data.
|
||||
*/
|
||||
private final StringBuilder eventBuilder;
|
||||
|
||||
/**
|
||||
* Current event's ID, if specified.
|
||||
*/
|
||||
private final AtomicReference<String> currentEventId;
|
||||
|
||||
/**
|
||||
* Current event's type, if specified.
|
||||
*/
|
||||
private final AtomicReference<String> currentEventType;
|
||||
|
||||
/**
|
||||
* The response information from the HTTP response. Send with each event to
|
||||
* provide context.
|
||||
*/
|
||||
private ResponseInfo responseInfo;
|
||||
|
||||
/**
|
||||
* Creates a new LineSubscriber that will emit parsed SSE events to the provided
|
||||
* sink.
|
||||
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
|
||||
* to
|
||||
*/
|
||||
public SseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
|
||||
this.sink = sink;
|
||||
this.eventBuilder = new StringBuilder();
|
||||
this.currentEventId = new AtomicReference<>();
|
||||
this.currentEventType = new AtomicReference<>();
|
||||
this.responseInfo = responseInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnSubscribe(Subscription subscription) {
|
||||
|
||||
sink.onRequest(n -> {
|
||||
subscription.request(n);
|
||||
});
|
||||
|
||||
// Register disposal callback to cancel subscription when Flux is disposed
|
||||
sink.onDispose(() -> {
|
||||
subscription.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnNext(String line) {
|
||||
if (line.isEmpty()) {
|
||||
// Empty line means end of event
|
||||
if (this.eventBuilder.length() > 0) {
|
||||
String eventData = this.eventBuilder.toString();
|
||||
SseEvent sseEvent = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
|
||||
|
||||
this.sink.next(new SseResponseEvent(responseInfo, sseEvent));
|
||||
this.eventBuilder.setLength(0);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (line.startsWith("data:")) {
|
||||
var matcher = EVENT_DATA_PATTERN.matcher(line);
|
||||
if (matcher.find()) {
|
||||
this.eventBuilder.append(matcher.group(1).trim()).append("\n");
|
||||
}
|
||||
upstream().request(1);
|
||||
}
|
||||
else if (line.startsWith("id:")) {
|
||||
var matcher = EVENT_ID_PATTERN.matcher(line);
|
||||
if (matcher.find()) {
|
||||
this.currentEventId.set(matcher.group(1).trim());
|
||||
}
|
||||
upstream().request(1);
|
||||
}
|
||||
else if (line.startsWith("event:")) {
|
||||
var matcher = EVENT_TYPE_PATTERN.matcher(line);
|
||||
if (matcher.find()) {
|
||||
this.currentEventType.set(matcher.group(1).trim());
|
||||
}
|
||||
upstream().request(1);
|
||||
}
|
||||
else if (line.startsWith(":")) {
|
||||
// Ignore comment lines starting with ":"
|
||||
// This is a no-op, just to skip comments
|
||||
logger.debug("Ignoring comment line: {}", line);
|
||||
upstream().request(1);
|
||||
}
|
||||
else {
|
||||
// If the response is not successful, emit an error
|
||||
this.sink.error(new McpTransportException(
|
||||
"Invalid SSE response. Status code: " + this.responseInfo.statusCode() + " Line: " + line));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnComplete() {
|
||||
if (this.eventBuilder.length() > 0) {
|
||||
String eventData = this.eventBuilder.toString();
|
||||
SseEvent sseEvent = new SseEvent(currentEventId.get(), currentEventType.get(), eventData.trim());
|
||||
this.sink.next(new SseResponseEvent(responseInfo, sseEvent));
|
||||
}
|
||||
this.sink.complete();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnError(Throwable throwable) {
|
||||
this.sink.error(throwable);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class AggregateSubscriber extends BaseSubscriber<String> {
|
||||
|
||||
/**
|
||||
* The sink for emitting parsed response events.
|
||||
*/
|
||||
private final FluxSink<ResponseEvent> sink;
|
||||
|
||||
/**
|
||||
* StringBuilder for accumulating multi-line event data.
|
||||
*/
|
||||
private final StringBuilder eventBuilder;
|
||||
|
||||
/**
|
||||
* The response information from the HTTP response. Send with each event to
|
||||
* provide context.
|
||||
*/
|
||||
private ResponseInfo responseInfo;
|
||||
|
||||
volatile boolean hasRequestedDemand = false;
|
||||
|
||||
/**
|
||||
* Creates a new JsonLineSubscriber that will emit parsed JSON-RPC messages.
|
||||
* @param sink the {@link FluxSink} to emit parsed {@link ResponseEvent} objects
|
||||
* to
|
||||
*/
|
||||
public AggregateSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
|
||||
this.sink = sink;
|
||||
this.eventBuilder = new StringBuilder();
|
||||
this.responseInfo = responseInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnSubscribe(Subscription subscription) {
|
||||
|
||||
sink.onRequest(n -> {
|
||||
if (!hasRequestedDemand) {
|
||||
subscription.request(Long.MAX_VALUE);
|
||||
}
|
||||
hasRequestedDemand = true;
|
||||
});
|
||||
|
||||
// Register disposal callback to cancel subscription when Flux is disposed
|
||||
sink.onDispose(subscription::cancel);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnNext(String line) {
|
||||
this.eventBuilder.append(line).append("\n");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnComplete() {
|
||||
|
||||
if (hasRequestedDemand) {
|
||||
String data = this.eventBuilder.toString();
|
||||
this.sink.next(new AggregateResponseEvent(responseInfo, data));
|
||||
}
|
||||
|
||||
this.sink.complete();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnError(Throwable throwable) {
|
||||
this.sink.error(throwable);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class BodilessResponseLineSubscriber extends BaseSubscriber<String> {
|
||||
|
||||
/**
|
||||
* The sink for emitting parsed response events.
|
||||
*/
|
||||
private final FluxSink<ResponseEvent> sink;
|
||||
|
||||
private final ResponseInfo responseInfo;
|
||||
|
||||
volatile boolean hasRequestedDemand = false;
|
||||
|
||||
public BodilessResponseLineSubscriber(ResponseInfo responseInfo, FluxSink<ResponseEvent> sink) {
|
||||
this.sink = sink;
|
||||
this.responseInfo = responseInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnSubscribe(Subscription subscription) {
|
||||
|
||||
sink.onRequest(n -> {
|
||||
if (!hasRequestedDemand) {
|
||||
subscription.request(Long.MAX_VALUE);
|
||||
}
|
||||
hasRequestedDemand = true;
|
||||
});
|
||||
|
||||
// Register disposal callback to cancel subscription when Flux is disposed
|
||||
sink.onDispose(() -> {
|
||||
subscription.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnComplete() {
|
||||
if (hasRequestedDemand) {
|
||||
// emit dummy event to be able to inspect the response info
|
||||
// this is a shortcut allowing for a more streamlined processing using
|
||||
// operator composition instead of having to deal with the
|
||||
// CompletableFuture along the Subscriber for inspecting the result
|
||||
this.sink.next(new DummyEvent(responseInfo));
|
||||
}
|
||||
this.sink.complete();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnError(Throwable throwable) {
|
||||
this.sink.error(throwable);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
|
||||
/**
|
||||
* Server parameters for stdio client.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public class ServerParameters {
|
||||
|
||||
// Environment variables to inherit by default
|
||||
private static final List<String> DEFAULT_INHERITED_ENV_VARS = System.getProperty("os.name")
|
||||
.toLowerCase()
|
||||
.contains("win")
|
||||
? Arrays.asList("APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "PROCESSOR_ARCHITECTURE",
|
||||
"SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE")
|
||||
: Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER");
|
||||
|
||||
@JsonProperty("command")
|
||||
private String command;
|
||||
|
||||
@JsonProperty("args")
|
||||
private List<String> args = new ArrayList<>();
|
||||
|
||||
@JsonProperty("env")
|
||||
private Map<String, String> env;
|
||||
|
||||
private ServerParameters(String command, List<String> args, Map<String, String> env) {
|
||||
Assert.notNull(command, "The command can not be null");
|
||||
Assert.notNull(args, "The args can not be null");
|
||||
|
||||
this.command = command;
|
||||
this.args = args;
|
||||
this.env = new HashMap<>(getDefaultEnvironment());
|
||||
if (env != null && !env.isEmpty()) {
|
||||
this.env.putAll(env);
|
||||
}
|
||||
}
|
||||
|
||||
public String getCommand() {
|
||||
return this.command;
|
||||
}
|
||||
|
||||
public List<String> getArgs() {
|
||||
return this.args;
|
||||
}
|
||||
|
||||
public Map<String, String> getEnv() {
|
||||
return this.env;
|
||||
}
|
||||
|
||||
public static Builder builder(String command) {
|
||||
return new Builder(command);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private String command;
|
||||
|
||||
private List<String> args = new ArrayList<>();
|
||||
|
||||
private Map<String, String> env = new HashMap<>();
|
||||
|
||||
public Builder(String command) {
|
||||
Assert.notNull(command, "The command can not be null");
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
public Builder args(String... args) {
|
||||
Assert.notNull(args, "The args can not be null");
|
||||
this.args = Arrays.asList(args);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder args(List<String> args) {
|
||||
Assert.notNull(args, "The args can not be null");
|
||||
this.args = new ArrayList<>(args);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder arg(String arg) {
|
||||
Assert.notNull(arg, "The arg can not be null");
|
||||
this.args.add(arg);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder env(Map<String, String> env) {
|
||||
if (env != null && !env.isEmpty()) {
|
||||
this.env.putAll(env);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addEnvVar(String key, String value) {
|
||||
Assert.notNull(key, "The key can not be null");
|
||||
Assert.notNull(value, "The value can not be null");
|
||||
this.env.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ServerParameters build() {
|
||||
return new ServerParameters(command, args, env);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a default environment object including only environment variables deemed
|
||||
* safe to inherit.
|
||||
*/
|
||||
private static Map<String, String> getDefaultEnvironment() {
|
||||
return System.getenv()
|
||||
.entrySet()
|
||||
.stream()
|
||||
.filter(entry -> DEFAULT_INHERITED_ENV_VARS.contains(entry.getKey()))
|
||||
.filter(entry -> entry.getValue() != null)
|
||||
.filter(entry -> !entry.getValue().startsWith("()"))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
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.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
/**
|
||||
* Implementation of the MCP Stdio transport that communicates with a server process using
|
||||
* standard input/output streams. Messages are exchanged as newline-delimited JSON-RPC
|
||||
* messages over stdin/stdout, with errors and debug information sent to stderr.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class StdioClientTransport implements McpClientTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StdioClientTransport.class);
|
||||
|
||||
private final Sinks.Many<JSONRPCMessage> inboundSink;
|
||||
|
||||
private final Sinks.Many<JSONRPCMessage> outboundSink;
|
||||
|
||||
/** The server process being communicated with */
|
||||
private Process process;
|
||||
|
||||
private McpJsonMapper jsonMapper;
|
||||
|
||||
/** Scheduler for handling inbound messages from the server process */
|
||||
private Scheduler inboundScheduler;
|
||||
|
||||
/** Scheduler for handling outbound messages to the server process */
|
||||
private Scheduler outboundScheduler;
|
||||
|
||||
/** Scheduler for handling error messages from the server process */
|
||||
private Scheduler errorScheduler;
|
||||
|
||||
/** Parameters for configuring and starting the server process */
|
||||
private final ServerParameters params;
|
||||
|
||||
private final Sinks.Many<String> errorSink;
|
||||
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
// visible for tests
|
||||
private Consumer<String> stdErrorHandler = error -> logger.info("STDERR Message received: {}", error);
|
||||
|
||||
/**
|
||||
* Creates a new StdioClientTransport with the specified parameters and JsonMapper.
|
||||
* @param params The parameters for configuring the server process
|
||||
* @param jsonMapper The JsonMapper to use for JSON serialization/deserialization
|
||||
*/
|
||||
public StdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper) {
|
||||
Assert.notNull(params, "The params can not be null");
|
||||
Assert.notNull(jsonMapper, "The JsonMapper can not be null");
|
||||
|
||||
this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
this.params = params;
|
||||
|
||||
this.jsonMapper = jsonMapper;
|
||||
|
||||
this.errorSink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
// Start threads
|
||||
this.inboundScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "inbound");
|
||||
this.outboundScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "outbound");
|
||||
this.errorScheduler = Schedulers.fromExecutorService(Executors.newSingleThreadExecutor(), "error");
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the server process and initializes the message processing streams. This
|
||||
* method sets up the process with the configured command, arguments, and environment,
|
||||
* then starts the inbound, outbound, and error processing threads.
|
||||
* @throws RuntimeException if the process fails to start or if the process streams
|
||||
* are null
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||
return Mono.<Void>fromRunnable(() -> {
|
||||
logger.info("MCP server starting.");
|
||||
handleIncomingMessages(handler);
|
||||
handleIncomingErrors();
|
||||
|
||||
// Prepare command and environment
|
||||
List<String> fullCommand = new ArrayList<>();
|
||||
fullCommand.add(params.getCommand());
|
||||
fullCommand.addAll(params.getArgs());
|
||||
|
||||
ProcessBuilder processBuilder = this.getProcessBuilder();
|
||||
processBuilder.command(fullCommand);
|
||||
processBuilder.environment().putAll(params.getEnv());
|
||||
|
||||
// Start the process
|
||||
try {
|
||||
this.process = processBuilder.start();
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new RuntimeException("Failed to start process with command: " + fullCommand, e);
|
||||
}
|
||||
|
||||
// Validate process streams
|
||||
if (this.process.getInputStream() == null || process.getOutputStream() == null) {
|
||||
this.process.destroy();
|
||||
throw new RuntimeException("Process input or output stream is null");
|
||||
}
|
||||
|
||||
// Start threads
|
||||
startInboundProcessing();
|
||||
startOutboundProcessing();
|
||||
startErrorProcessing();
|
||||
logger.info("MCP server started");
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a new ProcessBuilder instance. Protected to allow overriding in
|
||||
* tests.
|
||||
* @return A new ProcessBuilder instance
|
||||
*/
|
||||
protected ProcessBuilder getProcessBuilder() {
|
||||
return new ProcessBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the handler for processing transport-level errors.
|
||||
*
|
||||
* <p>
|
||||
* The provided handler will be called when errors occur during transport operations,
|
||||
* such as connection failures or protocol violations.
|
||||
* </p>
|
||||
* @param errorHandler a consumer that processes error messages
|
||||
*/
|
||||
public void setStdErrorHandler(Consumer<String> errorHandler) {
|
||||
this.stdErrorHandler = errorHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the server process to exit.
|
||||
* @throws RuntimeException if the process is interrupted while waiting
|
||||
*/
|
||||
public void awaitForExit() {
|
||||
try {
|
||||
this.process.waitFor();
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
throw new RuntimeException("Process interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the error processing thread that reads from the process's error stream.
|
||||
* Error messages are logged and emitted to the error sink.
|
||||
*/
|
||||
private void startErrorProcessing() {
|
||||
this.errorScheduler.schedule(() -> {
|
||||
try (BufferedReader processErrorReader = new BufferedReader(
|
||||
new InputStreamReader(process.getErrorStream()))) {
|
||||
String line;
|
||||
while (!isClosing && (line = processErrorReader.readLine()) != null) {
|
||||
try {
|
||||
if (!this.errorSink.tryEmitNext(line).isSuccess()) {
|
||||
if (!isClosing) {
|
||||
logger.error("Failed to emit error message");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
if (!isClosing) {
|
||||
logger.error("Error processing error message", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
if (!isClosing) {
|
||||
logger.error("Error reading from error stream", e);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
isClosing = true;
|
||||
errorSink.tryEmitComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleIncomingMessages(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> inboundMessageHandler) {
|
||||
this.inboundSink.asFlux()
|
||||
.flatMap(message -> Mono.just(message)
|
||||
.transform(inboundMessageHandler)
|
||||
.contextWrite(ctx -> ctx.put("observation", "myObservation")))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private void handleIncomingErrors() {
|
||||
this.errorSink.asFlux().subscribe(e -> {
|
||||
this.stdErrorHandler.accept(e);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(JSONRPCMessage message) {
|
||||
if (this.outboundSink.tryEmitNext(message).isSuccess()) {
|
||||
// TODO: essentially we could reschedule ourselves in some time and make
|
||||
// another attempt with the already read data but pause reading until
|
||||
// success
|
||||
// In this approach we delegate the retry and the backpressure onto the
|
||||
// caller. This might be enough for most cases.
|
||||
return Mono.empty();
|
||||
}
|
||||
else {
|
||||
return Mono.error(new RuntimeException("Failed to enqueue message"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the inbound processing thread that reads JSON-RPC messages from the
|
||||
* process's input stream. Messages are deserialized and emitted to the inbound sink.
|
||||
*/
|
||||
private void startInboundProcessing() {
|
||||
this.inboundScheduler.schedule(() -> {
|
||||
try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while (!isClosing && (line = processReader.readLine()) != null) {
|
||||
try {
|
||||
JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, line);
|
||||
if (!this.inboundSink.tryEmitNext(message).isSuccess()) {
|
||||
if (!isClosing) {
|
||||
logger.error("Failed to enqueue inbound message: {}", message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
if (!isClosing) {
|
||||
logger.error("Error processing inbound message for line: {}", line, e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
if (!isClosing) {
|
||||
logger.error("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
isClosing = true;
|
||||
inboundSink.tryEmitComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the outbound processing thread that writes JSON-RPC messages to the
|
||||
* process's output stream. Messages are serialized to JSON and written with a newline
|
||||
* delimiter.
|
||||
*/
|
||||
private void startOutboundProcessing() {
|
||||
this.handleOutbound(messages -> messages
|
||||
// this bit is important since writes come from user threads, and we
|
||||
// want to ensure that the actual writing happens on a dedicated thread
|
||||
.publishOn(outboundScheduler)
|
||||
.handle((message, s) -> {
|
||||
if (message != null && !isClosing) {
|
||||
try {
|
||||
String jsonMessage = jsonMapper.writeValueAsString(message);
|
||||
// Escape any embedded newlines in the JSON message as per spec:
|
||||
// https://spec.modelcontextprotocol.io/specification/basic/transports/#stdio
|
||||
// - Messages are delimited by newlines, and MUST NOT contain
|
||||
// embedded newlines.
|
||||
jsonMessage = jsonMessage.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n");
|
||||
|
||||
var os = this.process.getOutputStream();
|
||||
synchronized (os) {
|
||||
os.write(jsonMessage.getBytes(StandardCharsets.UTF_8));
|
||||
os.write("\n".getBytes(StandardCharsets.UTF_8));
|
||||
os.flush();
|
||||
}
|
||||
s.next(message);
|
||||
}
|
||||
catch (IOException e) {
|
||||
s.error(new RuntimeException(e));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
protected void handleOutbound(Function<Flux<JSONRPCMessage>, Flux<JSONRPCMessage>> outboundConsumer) {
|
||||
outboundConsumer.apply(outboundSink.asFlux()).doOnComplete(() -> {
|
||||
isClosing = true;
|
||||
outboundSink.tryEmitComplete();
|
||||
}).doOnError(e -> {
|
||||
if (!isClosing) {
|
||||
logger.error("Error in outbound processing", e);
|
||||
isClosing = true;
|
||||
outboundSink.tryEmitComplete();
|
||||
}
|
||||
}).subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully closes the transport by destroying the process and disposing of the
|
||||
* schedulers. This method sends a TERM signal to the process and waits for it to exit
|
||||
* before cleaning up resources.
|
||||
* @return A Mono that completes when the transport is closed
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> {
|
||||
isClosing = true;
|
||||
logger.debug("Initiating graceful shutdown");
|
||||
}).then(Mono.<Void>defer(() -> {
|
||||
// First complete all sinks to stop accepting new messages
|
||||
inboundSink.tryEmitComplete();
|
||||
outboundSink.tryEmitComplete();
|
||||
errorSink.tryEmitComplete();
|
||||
|
||||
// Give a short time for any pending messages to be processed
|
||||
return Mono.delay(Duration.ofMillis(100)).then();
|
||||
})).then(Mono.defer(() -> {
|
||||
logger.debug("Sending TERM to process");
|
||||
if (this.process != null) {
|
||||
this.process.destroy();
|
||||
return Mono.fromFuture(process.onExit());
|
||||
}
|
||||
else {
|
||||
logger.warn("Process not started");
|
||||
return Mono.<Process>empty();
|
||||
}
|
||||
})).doOnNext(process -> {
|
||||
if (process.exitValue() != 0) {
|
||||
logger.warn("Process terminated with code {}", process.exitValue());
|
||||
}
|
||||
else {
|
||||
logger.info("MCP server process stopped");
|
||||
}
|
||||
}).then(Mono.fromRunnable(() -> {
|
||||
try {
|
||||
// The Threads are blocked on readLine so disposeGracefully would not
|
||||
// interrupt them, therefore we issue an async hard dispose.
|
||||
inboundScheduler.dispose();
|
||||
errorScheduler.dispose();
|
||||
outboundScheduler.dispose();
|
||||
|
||||
logger.debug("Graceful shutdown completed");
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Error during graceful shutdown", e);
|
||||
}
|
||||
})).then().subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
public Sinks.Many<String> getErrorSink() {
|
||||
return this.errorSink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
return this.jsonMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
package io.modelcontextprotocol.client.transport.customizer;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.util.List;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Composable {@link McpAsyncHttpClientRequestCustomizer} that applies multiple
|
||||
* customizers, in order.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
public class DelegatingMcpAsyncHttpClientRequestCustomizer implements McpAsyncHttpClientRequestCustomizer {
|
||||
|
||||
private final List<McpAsyncHttpClientRequestCustomizer> customizers;
|
||||
|
||||
public DelegatingMcpAsyncHttpClientRequestCustomizer(List<McpAsyncHttpClientRequestCustomizer> customizers) {
|
||||
Assert.notNull(customizers, "Customizers must not be null");
|
||||
this.customizers = customizers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
|
||||
String body, McpTransportContext context) {
|
||||
var result = Mono.just(builder);
|
||||
for (var customizer : this.customizers) {
|
||||
result = result.flatMap(b -> Mono.from(customizer.customize(b, method, endpoint, body, context)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport.customizer;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.util.List;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
|
||||
/**
|
||||
* Composable {@link McpSyncHttpClientRequestCustomizer} that applies multiple
|
||||
* customizers, in order.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
public class DelegatingMcpSyncHttpClientRequestCustomizer implements McpSyncHttpClientRequestCustomizer {
|
||||
|
||||
private final List<McpSyncHttpClientRequestCustomizer> delegates;
|
||||
|
||||
public DelegatingMcpSyncHttpClientRequestCustomizer(List<McpSyncHttpClientRequestCustomizer> customizers) {
|
||||
Assert.notNull(customizers, "Customizers must not be null");
|
||||
this.delegates = customizers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
|
||||
McpTransportContext context) {
|
||||
this.delegates.forEach(delegate -> delegate.customize(builder, method, endpoint, body, context));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport.customizer;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.util.annotation.Nullable;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
|
||||
/**
|
||||
* Customize {@link HttpRequest.Builder} before executing the request, in either SSE or
|
||||
* Streamable HTTP transport.
|
||||
* <p>
|
||||
* When used in a non-blocking context, implementations MUST be non-blocking.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
public interface McpAsyncHttpClientRequestCustomizer {
|
||||
|
||||
Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
|
||||
@Nullable String body, McpTransportContext context);
|
||||
|
||||
McpAsyncHttpClientRequestCustomizer NOOP = new Noop();
|
||||
|
||||
/**
|
||||
* Wrap a sync implementation in an async wrapper.
|
||||
* <p>
|
||||
* Do NOT wrap a blocking implementation for use in a non-blocking context. For a
|
||||
* blocking implementation, consider using {@link Schedulers#boundedElastic()}.
|
||||
*/
|
||||
static McpAsyncHttpClientRequestCustomizer fromSync(McpSyncHttpClientRequestCustomizer customizer) {
|
||||
return (builder, method, uri, body, context) -> Mono.fromSupplier(() -> {
|
||||
customizer.customize(builder, method, uri, body, context);
|
||||
return builder;
|
||||
});
|
||||
}
|
||||
|
||||
class Noop implements McpAsyncHttpClientRequestCustomizer {
|
||||
|
||||
@Override
|
||||
public Publisher<HttpRequest.Builder> customize(HttpRequest.Builder builder, String method, URI endpoint,
|
||||
String body, McpTransportContext context) {
|
||||
return Mono.just(builder);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport.customizer;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
|
||||
import reactor.util.annotation.Nullable;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient.SyncSpec;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
|
||||
/**
|
||||
* Customize {@link HttpRequest.Builder} before executing the request, either in SSE or
|
||||
* Streamable HTTP transport. Do not rely on thread-locals in this implementation, instead
|
||||
* use {@link SyncSpec#transportContextProvider} to extract context, and then consume it
|
||||
* through {@link McpTransportContext}.
|
||||
*
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
public interface McpSyncHttpClientRequestCustomizer {
|
||||
|
||||
void customize(HttpRequest.Builder builder, String method, URI endpoint, @Nullable String body,
|
||||
McpTransportContext context);
|
||||
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.common;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
|
||||
/**
|
||||
* Default implementation for {@link McpTransportContext} which uses a map as storage.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
* @author Daniel Garnier-Moiroux
|
||||
*/
|
||||
class DefaultMcpTransportContext implements McpTransportContext {
|
||||
|
||||
private final Map<String, Object> metadata;
|
||||
|
||||
DefaultMcpTransportContext(Map<String, Object> metadata) {
|
||||
Assert.notNull(metadata, "The metadata cannot be null");
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(String key) {
|
||||
return this.metadata.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || getClass() != o.getClass())
|
||||
return false;
|
||||
|
||||
DefaultMcpTransportContext that = (DefaultMcpTransportContext) o;
|
||||
return this.metadata.equals(that.metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.metadata.hashCode();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.common;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Context associated with the transport layer. It allows to add transport-level metadata
|
||||
* for use further down the line. Specifically, it can be beneficial to extract HTTP
|
||||
* request metadata for use in MCP feature implementations.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public interface McpTransportContext {
|
||||
|
||||
/**
|
||||
* Key for use in Reactor Context to transport the context to user land.
|
||||
*/
|
||||
String KEY = "MCP_TRANSPORT_CONTEXT";
|
||||
|
||||
/**
|
||||
* An empty, unmodifiable context.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
McpTransportContext EMPTY = new DefaultMcpTransportContext(Collections.EMPTY_MAP);
|
||||
|
||||
/**
|
||||
* Create an unmodifiable context containing the given metadata.
|
||||
* @param metadata the transport metadata
|
||||
* @return the context containing the metadata
|
||||
*/
|
||||
static McpTransportContext create(Map<String, Object> metadata) {
|
||||
return new DefaultMcpTransportContext(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a value from the context.
|
||||
* @param key the key under the data is expected
|
||||
* @return the associated value or {@code null} if missing.
|
||||
*/
|
||||
Object get(String key);
|
||||
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
class DefaultMcpStatelessServerHandler implements McpStatelessServerHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DefaultMcpStatelessServerHandler.class);
|
||||
|
||||
Map<String, McpStatelessRequestHandler<?>> requestHandlers;
|
||||
|
||||
Map<String, McpStatelessNotificationHandler> notificationHandlers;
|
||||
|
||||
public DefaultMcpStatelessServerHandler(Map<String, McpStatelessRequestHandler<?>> requestHandlers,
|
||||
Map<String, McpStatelessNotificationHandler> notificationHandlers) {
|
||||
this.requestHandlers = requestHandlers;
|
||||
this.notificationHandlers = notificationHandlers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<McpSchema.JSONRPCResponse> handleRequest(McpTransportContext transportContext,
|
||||
McpSchema.JSONRPCRequest request) {
|
||||
McpStatelessRequestHandler<?> requestHandler = this.requestHandlers.get(request.method());
|
||||
if (requestHandler == null) {
|
||||
return Mono.error(new McpError("Missing handler for request type: " + request.method()));
|
||||
}
|
||||
return requestHandler.handle(transportContext, request.params())
|
||||
.map(result -> new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), result, null))
|
||||
.onErrorResume(t -> {
|
||||
McpSchema.JSONRPCResponse.JSONRPCError error;
|
||||
if (t instanceof McpError mcpError && mcpError.getJsonRpcError() != null) {
|
||||
error = mcpError.getJsonRpcError();
|
||||
}
|
||||
else {
|
||||
error = new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR,
|
||||
t.getMessage(), null);
|
||||
}
|
||||
return Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null, error));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> handleNotification(McpTransportContext transportContext,
|
||||
McpSchema.JSONRPCNotification notification) {
|
||||
McpStatelessNotificationHandler notificationHandler = this.notificationHandlers.get(notification.method());
|
||||
if (notificationHandler == null) {
|
||||
logger.warn("Missing handler for notification type: {}", notification.method());
|
||||
return Mono.empty();
|
||||
}
|
||||
return notificationHandler.handle(transportContext, notification.params());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,922 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import io.modelcontextprotocol.spec.DefaultMcpStreamableServerSessionFactory;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProviderBase;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
|
||||
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
|
||||
import io.modelcontextprotocol.spec.McpClientSession;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCResponse;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
|
||||
import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProviderBase;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.DeafaultMcpUriTemplateManagerFactory;
|
||||
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @author Jihoon Kim
|
||||
* @see McpServer
|
||||
* @see McpSchema
|
||||
* @see McpClientSession
|
||||
*/
|
||||
public class McpAsyncServer {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpAsyncServer.class);
|
||||
|
||||
private final McpServerTransportProviderBase mcpTransportProvider;
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
private final JsonSchemaValidator jsonSchemaValidator;
|
||||
|
||||
private final McpSchema.ServerCapabilities serverCapabilities;
|
||||
|
||||
private final McpSchema.Implementation serverInfo;
|
||||
|
||||
private final 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 final ConcurrentHashMap<McpSchema.CompleteReference, McpServerFeatures.AsyncCompletionSpecification> completions = new ConcurrentHashMap<>();
|
||||
|
||||
private List<String> protocolVersions;
|
||||
|
||||
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory();
|
||||
|
||||
/**
|
||||
* 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 jsonMapper The JsonMapper to use for JSON serialization/deserialization
|
||||
*/
|
||||
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
|
||||
McpServerFeatures.Async features, Duration requestTimeout,
|
||||
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
|
||||
this.mcpTransportProvider = mcpTransportProvider;
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.serverInfo = features.serverInfo();
|
||||
this.serverCapabilities = features.serverCapabilities().mutate().logging().build();
|
||||
this.instructions = features.instructions();
|
||||
this.tools.addAll(withStructuredOutputHandling(jsonSchemaValidator, features.tools()));
|
||||
this.resources.putAll(features.resources());
|
||||
this.resourceTemplates.addAll(features.resourceTemplates());
|
||||
this.prompts.putAll(features.prompts());
|
||||
this.completions.putAll(features.completions());
|
||||
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||
|
||||
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
||||
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
||||
|
||||
this.protocolVersions = mcpTransportProvider.protocolVersions();
|
||||
|
||||
mcpTransportProvider.setSessionFactory(transport -> new McpServerSession(UUID.randomUUID().toString(),
|
||||
requestTimeout, transport, this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers));
|
||||
}
|
||||
|
||||
McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
|
||||
McpServerFeatures.Async features, Duration requestTimeout,
|
||||
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
|
||||
this.mcpTransportProvider = mcpTransportProvider;
|
||||
this.jsonMapper = jsonMapper;
|
||||
this.serverInfo = features.serverInfo();
|
||||
this.serverCapabilities = features.serverCapabilities().mutate().logging().build();
|
||||
this.instructions = features.instructions();
|
||||
this.tools.addAll(withStructuredOutputHandling(jsonSchemaValidator, features.tools()));
|
||||
this.resources.putAll(features.resources());
|
||||
this.resourceTemplates.addAll(features.resourceTemplates());
|
||||
this.prompts.putAll(features.prompts());
|
||||
this.completions.putAll(features.completions());
|
||||
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||
|
||||
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
||||
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
||||
|
||||
this.protocolVersions = mcpTransportProvider.protocolVersions();
|
||||
|
||||
mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout,
|
||||
this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers));
|
||||
}
|
||||
|
||||
private Map<String, McpNotificationHandler> prepareNotificationHandlers(McpServerFeatures.Async features) {
|
||||
Map<String, McpNotificationHandler> 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));
|
||||
return notificationHandlers;
|
||||
}
|
||||
|
||||
private Map<String, McpRequestHandler<?>> prepareRequestHandlers() {
|
||||
Map<String, McpRequestHandler<?>> 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) -> Mono.just(Map.of()));
|
||||
|
||||
// Add tools API handlers if the tool capability is enabled
|
||||
if (this.serverCapabilities.tools() != null) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
requestHandlers.put(McpSchema.METHOD_LOGGING_SET_LEVEL, setLoggerRequestHandler());
|
||||
}
|
||||
|
||||
// Add completion API handlers if the completion capability is enabled
|
||||
if (this.serverCapabilities.completions() != null) {
|
||||
requestHandlers.put(McpSchema.METHOD_COMPLETION_COMPLETE, completionCompleteRequestHandler());
|
||||
}
|
||||
return requestHandlers;
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Lifecycle Management
|
||||
// ---------------------------------------
|
||||
private Mono<McpSchema.InitializeResult> asyncInitializeRequestHandler(
|
||||
McpSchema.InitializeRequest initializeRequest) {
|
||||
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 suggest the {} version instead",
|
||||
initializeRequest.protocolVersion(), serverProtocolVersion);
|
||||
}
|
||||
|
||||
return Mono.just(new McpSchema.InitializeResult(serverProtocolVersion, this.serverCapabilities,
|
||||
this.serverInfo, this.instructions));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.mcpTransportProvider.closeGracefully();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the server immediately.
|
||||
*/
|
||||
public void close() {
|
||||
this.mcpTransportProvider.close();
|
||||
}
|
||||
|
||||
private McpNotificationHandler asyncRootsListChangedNotificationHandler(
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
|
||||
return (exchange, params) -> exchange.listRoots()
|
||||
.flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
|
||||
.flatMap(consumer -> Mono.defer(() -> consumer.apply(exchange, listRootsResult.roots())))
|
||||
.onErrorResume(error -> {
|
||||
logger.error("Error handling roots list change notification", error);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then());
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tool Management
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* Add a new tool call 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) {
|
||||
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 && toolSpecification.callHandler() == 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"));
|
||||
}
|
||||
|
||||
var wrappedToolSpecification = withStructuredOutputHandling(this.jsonSchemaValidator, toolSpecification);
|
||||
|
||||
return Mono.defer(() -> {
|
||||
// Check for duplicate tool names
|
||||
if (this.tools.stream().anyMatch(th -> th.tool().name().equals(wrappedToolSpecification.tool().name()))) {
|
||||
return Mono.error(
|
||||
new McpError("Tool with name '" + wrappedToolSpecification.tool().name() + "' already exists"));
|
||||
}
|
||||
|
||||
this.tools.add(wrappedToolSpecification);
|
||||
logger.debug("Added tool handler: {}", wrappedToolSpecification.tool().name());
|
||||
|
||||
if (this.serverCapabilities.tools().listChanged()) {
|
||||
return notifyToolsListChanged();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private static class StructuredOutputCallToolHandler
|
||||
implements BiFunction<McpAsyncServerExchange, McpSchema.CallToolRequest, Mono<McpSchema.CallToolResult>> {
|
||||
|
||||
private final BiFunction<McpAsyncServerExchange, McpSchema.CallToolRequest, Mono<McpSchema.CallToolResult>> delegateCallToolResult;
|
||||
|
||||
private final JsonSchemaValidator jsonSchemaValidator;
|
||||
|
||||
private final Map<String, Object> outputSchema;
|
||||
|
||||
public StructuredOutputCallToolHandler(JsonSchemaValidator jsonSchemaValidator,
|
||||
Map<String, Object> outputSchema,
|
||||
BiFunction<McpAsyncServerExchange, McpSchema.CallToolRequest, Mono<McpSchema.CallToolResult>> delegateHandler) {
|
||||
|
||||
Assert.notNull(jsonSchemaValidator, "JsonSchemaValidator must not be null");
|
||||
Assert.notNull(delegateHandler, "Delegate call tool result handler must not be null");
|
||||
|
||||
this.delegateCallToolResult = delegateHandler;
|
||||
this.outputSchema = outputSchema;
|
||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<CallToolResult> apply(McpAsyncServerExchange exchange, McpSchema.CallToolRequest request) {
|
||||
|
||||
return this.delegateCallToolResult.apply(exchange, request).map(result -> {
|
||||
|
||||
if (Boolean.TRUE.equals(result.isError())) {
|
||||
// If the tool call resulted in an error, skip further validation
|
||||
return result;
|
||||
}
|
||||
|
||||
if (outputSchema == null) {
|
||||
if (result.structuredContent() != null) {
|
||||
logger.warn(
|
||||
"Tool call with no outputSchema is not expected to have a result with structured content, but got: {}",
|
||||
result.structuredContent());
|
||||
}
|
||||
// Pass through. No validation is required if no output schema is
|
||||
// provided.
|
||||
return result;
|
||||
}
|
||||
|
||||
// If an output schema is provided, servers MUST provide structured
|
||||
// results that conform to this schema.
|
||||
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema
|
||||
if (result.structuredContent() == null) {
|
||||
logger.warn(
|
||||
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
|
||||
return new CallToolResult(
|
||||
"Response missing structured content which is expected when calling tool with non-empty outputSchema",
|
||||
true);
|
||||
}
|
||||
|
||||
// Validate the result against the output schema
|
||||
var validation = this.jsonSchemaValidator.validate(outputSchema, result.structuredContent());
|
||||
|
||||
if (!validation.valid()) {
|
||||
logger.warn("Tool call result validation failed: {}", validation.errorMessage());
|
||||
return new CallToolResult(validation.errorMessage(), true);
|
||||
}
|
||||
|
||||
if (Utils.isEmpty(result.content())) {
|
||||
// For backwards compatibility, a tool that returns structured
|
||||
// content SHOULD also return functionally equivalent unstructured
|
||||
// content. (For example, serialized JSON can be returned in a
|
||||
// TextContent block.)
|
||||
// https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
|
||||
|
||||
return CallToolResult.builder()
|
||||
.content(List.of(new McpSchema.TextContent(validation.jsonStructuredOutput())))
|
||||
.isError(result.isError())
|
||||
.structuredContent(result.structuredContent())
|
||||
.build();
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static List<McpServerFeatures.AsyncToolSpecification> withStructuredOutputHandling(
|
||||
JsonSchemaValidator jsonSchemaValidator, List<McpServerFeatures.AsyncToolSpecification> tools) {
|
||||
|
||||
if (Utils.isEmpty(tools)) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
return tools.stream().map(tool -> withStructuredOutputHandling(jsonSchemaValidator, tool)).toList();
|
||||
}
|
||||
|
||||
private static McpServerFeatures.AsyncToolSpecification withStructuredOutputHandling(
|
||||
JsonSchemaValidator jsonSchemaValidator, McpServerFeatures.AsyncToolSpecification toolSpecification) {
|
||||
|
||||
if (toolSpecification.callHandler() instanceof StructuredOutputCallToolHandler) {
|
||||
// If the tool is already wrapped, return it as is
|
||||
return toolSpecification;
|
||||
}
|
||||
|
||||
if (toolSpecification.tool().outputSchema() == null) {
|
||||
// If the tool does not have an output schema, return it as is
|
||||
return toolSpecification;
|
||||
}
|
||||
|
||||
return McpServerFeatures.AsyncToolSpecification.builder()
|
||||
.tool(toolSpecification.tool())
|
||||
.callHandler(new StructuredOutputCallToolHandler(jsonSchemaValidator,
|
||||
toolSpecification.tool().outputSchema(), toolSpecification.callHandler()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
|
||||
|
||||
return Mono.just(new McpSchema.ListToolsResult(tools, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpRequestHandler<CallToolResult> toolsCallRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.CallToolRequest callToolRequest = jsonMapper.convertValue(params,
|
||||
new TypeRef<McpSchema.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(new JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INVALID_PARAMS,
|
||||
"Unknown tool: invalid_tool_name", "Tool not found: " + callToolRequest.name())));
|
||||
}
|
||||
|
||||
return toolSpecification.get().callHandler().apply(exchange, callToolRequest);
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Resource Management
|
||||
// ---------------------------------------
|
||||
|
||||
/**
|
||||
* Add a new resource handler at runtime.
|
||||
* @param resourceSpecification The resource handler to add
|
||||
* @return Mono that completes when clients have been notified of the change
|
||||
*/
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies clients that the resources have updated.
|
||||
* @return A Mono that completes when all clients have been notified
|
||||
*/
|
||||
public Mono<Void> notifyResourcesUpdated(McpSchema.ResourcesUpdatedNotification resourcesUpdatedNotification) {
|
||||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED,
|
||||
resourcesUpdatedNotification);
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
var resourceList = this.resources.values()
|
||||
.stream()
|
||||
.map(McpServerFeatures.AsyncResourceSpecification::resource)
|
||||
.filter(resource -> !resource.uri().contains("{"))
|
||||
.toList();
|
||||
return Mono.just(new McpSchema.ListResourcesResult(resourceList, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
|
||||
return (exchange, params) -> Mono
|
||||
.just(new McpSchema.ListResourceTemplatesResult(this.getResourceTemplates(), null));
|
||||
|
||||
}
|
||||
|
||||
private List<McpSchema.ResourceTemplate> getResourceTemplates() {
|
||||
var list = new ArrayList<>(this.resourceTemplates);
|
||||
List<ResourceTemplate> resourceTemplates = this.resources.keySet()
|
||||
.stream()
|
||||
.filter(uri -> uri.contains("{"))
|
||||
.map(uri -> {
|
||||
var resource = this.resources.get(uri).resource();
|
||||
var template = new McpSchema.ResourceTemplate(resource.uri(), resource.name(), resource.title(),
|
||||
resource.description(), resource.mimeType(), resource.annotations());
|
||||
return template;
|
||||
})
|
||||
.toList();
|
||||
|
||||
list.addAll(resourceTemplates);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.ReadResourceRequest resourceRequest = jsonMapper.convertValue(params,
|
||||
new TypeRef<McpSchema.ReadResourceRequest>() {
|
||||
});
|
||||
var resourceUri = resourceRequest.uri();
|
||||
|
||||
McpServerFeatures.AsyncResourceSpecification specification = this.resources.values()
|
||||
.stream()
|
||||
.filter(resourceSpecification -> this.uriTemplateManagerFactory
|
||||
.create(resourceSpecification.resource().uri())
|
||||
.matches(resourceUri))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new McpError("Resource not found: " + resourceUri));
|
||||
|
||||
return Mono.defer(() -> specification.readHandler().apply(exchange, resourceRequest));
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// 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) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
// TODO: Implement pagination
|
||||
// McpSchema.PaginatedRequest request = objectMapper.convertValue(params,
|
||||
// new TypeReference<McpSchema.PaginatedRequest>() {
|
||||
// });
|
||||
|
||||
var promptList = this.prompts.values()
|
||||
.stream()
|
||||
.map(McpServerFeatures.AsyncPromptSpecification::prompt)
|
||||
.toList();
|
||||
|
||||
return Mono.just(new McpSchema.ListPromptsResult(promptList, null));
|
||||
};
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.GetPromptRequest promptRequest = jsonMapper.convertValue(params,
|
||||
new TypeRef<McpSchema.GetPromptRequest>() {
|
||||
});
|
||||
|
||||
// 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 Mono.defer(() -> specification.promptHandler().apply(exchange, promptRequest));
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// 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) {
|
||||
|
||||
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 McpRequestHandler<Object> setLoggerRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
return Mono.defer(() -> {
|
||||
|
||||
SetLevelRequest newMinLoggingLevel = jsonMapper.convertValue(params, new TypeRef<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());
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private McpRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.CompleteRequest request = parseCompletionParams(params);
|
||||
|
||||
if (request.ref() == null) {
|
||||
return Mono.error(new McpError("ref must not be null"));
|
||||
}
|
||||
|
||||
if (request.ref().type() == null) {
|
||||
return Mono.error(new McpError("type must not be null"));
|
||||
}
|
||||
|
||||
String type = request.ref().type();
|
||||
|
||||
String argumentName = request.argument().name();
|
||||
|
||||
// check if the referenced resource exists
|
||||
if (type.equals("ref/prompt") && request.ref() instanceof McpSchema.PromptReference promptReference) {
|
||||
McpServerFeatures.AsyncPromptSpecification promptSpec = this.prompts.get(promptReference.name());
|
||||
if (promptSpec == null) {
|
||||
return Mono.error(new McpError("Prompt not found: " + promptReference.name()));
|
||||
}
|
||||
if (!promptSpec.prompt()
|
||||
.arguments()
|
||||
.stream()
|
||||
.filter(arg -> arg.name().equals(argumentName))
|
||||
.findFirst()
|
||||
.isPresent()) {
|
||||
|
||||
return Mono.error(new McpError("Argument not found: " + argumentName));
|
||||
}
|
||||
}
|
||||
|
||||
if (type.equals("ref/resource") && request.ref() instanceof McpSchema.ResourceReference resourceReference) {
|
||||
McpServerFeatures.AsyncResourceSpecification resourceSpec = this.resources.get(resourceReference.uri());
|
||||
if (resourceSpec == null) {
|
||||
return Mono.error(new McpError("Resource not found: " + resourceReference.uri()));
|
||||
}
|
||||
if (!uriTemplateManagerFactory.create(resourceSpec.resource().uri())
|
||||
.getVariableNames()
|
||||
.contains(argumentName)) {
|
||||
return Mono.error(new McpError("Argument not found: " + argumentName));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
McpServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref());
|
||||
|
||||
if (specification == null) {
|
||||
return Mono.error(new McpError("AsyncCompletionSpecification not found: " + request.ref()));
|
||||
}
|
||||
|
||||
return Mono.defer(() -> specification.completionHandler().apply(exchange, request));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest}
|
||||
* object.
|
||||
* <p>
|
||||
* This method manually extracts the `ref` and `argument` fields from the input map,
|
||||
* determines the correct reference type (either prompt or resource), and constructs a
|
||||
* fully-typed {@code CompleteRequest} instance.
|
||||
* @param object the raw request parameters, expected to be a Map containing "ref" and
|
||||
* "argument" entries.
|
||||
* @return a {@link McpSchema.CompleteRequest} representing the structured completion
|
||||
* request.
|
||||
* @throws IllegalArgumentException if the "ref" type is not recognized.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private McpSchema.CompleteRequest parseCompletionParams(Object object) {
|
||||
Map<String, Object> params = (Map<String, Object>) object;
|
||||
Map<String, Object> refMap = (Map<String, Object>) params.get("ref");
|
||||
Map<String, Object> argMap = (Map<String, Object>) params.get("argument");
|
||||
Map<String, Object> contextMap = (Map<String, Object>) params.get("context");
|
||||
Map<String, Object> meta = (Map<String, Object>) params.get("_meta");
|
||||
|
||||
String refType = (String) refMap.get("type");
|
||||
|
||||
McpSchema.CompleteReference ref = switch (refType) {
|
||||
case "ref/prompt" -> new McpSchema.PromptReference(refType, (String) refMap.get("name"),
|
||||
refMap.get("title") != null ? (String) refMap.get("title") : null);
|
||||
case "ref/resource" -> new McpSchema.ResourceReference(refType, (String) refMap.get("uri"));
|
||||
default -> throw new IllegalArgumentException("Invalid ref type: " + refType);
|
||||
};
|
||||
|
||||
String argName = (String) argMap.get("name");
|
||||
String argValue = (String) argMap.get("value");
|
||||
McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument(argName,
|
||||
argValue);
|
||||
|
||||
McpSchema.CompleteRequest.CompleteContext context = null;
|
||||
if (contextMap != null) {
|
||||
Map<String, String> arguments = (Map<String, String>) contextMap.get("arguments");
|
||||
context = new McpSchema.CompleteRequest.CompleteContext(arguments);
|
||||
}
|
||||
|
||||
return new McpSchema.CompleteRequest(ref, argument, meta, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpLoggableSession;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
|
||||
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
|
||||
import io.modelcontextprotocol.spec.McpSession;
|
||||
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 String sessionId;
|
||||
|
||||
private final McpLoggableSession session;
|
||||
|
||||
private final McpSchema.ClientCapabilities clientCapabilities;
|
||||
|
||||
private final McpSchema.Implementation clientInfo;
|
||||
|
||||
private final McpTransportContext transportContext;
|
||||
|
||||
private static final TypeRef<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
private static final TypeRef<McpSchema.ListRootsResult> LIST_ROOTS_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
private static final TypeRef<McpSchema.ElicitResult> ELICITATION_RESULT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
public static final TypeRef<Object> OBJECT_TYPE_REF = new TypeRef<>() {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @deprecated Use
|
||||
* {@link #McpAsyncServerExchange(String, McpLoggableSession, McpSchema.ClientCapabilities, McpSchema.Implementation, McpTransportContext)}
|
||||
*/
|
||||
@Deprecated
|
||||
public McpAsyncServerExchange(McpSession session, McpSchema.ClientCapabilities clientCapabilities,
|
||||
McpSchema.Implementation clientInfo) {
|
||||
this.sessionId = null;
|
||||
if (!(session instanceof McpLoggableSession)) {
|
||||
throw new IllegalArgumentException("Expecting session to be a McpLoggableSession instance");
|
||||
}
|
||||
this.session = (McpLoggableSession) session;
|
||||
this.clientCapabilities = clientCapabilities;
|
||||
this.clientInfo = clientInfo;
|
||||
this.transportContext = McpTransportContext.EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param transportContext context associated with the client as extracted from the
|
||||
* transport
|
||||
*/
|
||||
public McpAsyncServerExchange(String sessionId, McpLoggableSession session,
|
||||
McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
|
||||
McpTransportContext transportContext) {
|
||||
this.sessionId = sessionId;
|
||||
this.session = session;
|
||||
this.clientCapabilities = clientCapabilities;
|
||||
this.clientInfo = clientInfo;
|
||||
this.transportContext = transportContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the {@link McpTransportContext} associated with the transport layer. For
|
||||
* HTTP transports it can contain the metadata associated with the HTTP request that
|
||||
* triggered the processing.
|
||||
* @return the transport context object
|
||||
*/
|
||||
public McpTransportContext transportContext() {
|
||||
return this.transportContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the Session ID.
|
||||
* @return session ID string
|
||||
*/
|
||||
public String sessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new elicitation. MCP provides a standardized way for servers to request
|
||||
* additional information from users through the client during interactions. This flow
|
||||
* allows clients to maintain control over user interactions and data sharing while
|
||||
* enabling servers to gather necessary information dynamically. Servers can request
|
||||
* structured data from users with optional JSON schemas to validate responses.
|
||||
* @param elicitRequest The request to create a new elicitation
|
||||
* @return A Mono that completes when the elicitation has been resolved.
|
||||
* @see McpSchema.ElicitRequest
|
||||
* @see McpSchema.ElicitResult
|
||||
* @see <a href=
|
||||
* "https://spec.modelcontextprotocol.io/specification/client/elicitation/">Elicitation
|
||||
* Specification</a>
|
||||
*/
|
||||
public Mono<McpSchema.ElicitResult> createElicitation(McpSchema.ElicitRequest elicitRequest) {
|
||||
if (this.clientCapabilities == null) {
|
||||
return Mono.error(new McpError("Client must be initialized. Call the initialize method first!"));
|
||||
}
|
||||
if (this.clientCapabilities.elicitation() == null) {
|
||||
return Mono.error(new McpError("Client must be configured with elicitation capabilities"));
|
||||
}
|
||||
return this.session.sendRequest(McpSchema.METHOD_ELICITATION_CREATE, elicitRequest,
|
||||
ELICITATION_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() {
|
||||
|
||||
// @formatter:off
|
||||
return this.listRoots(McpSchema.FIRST_PAGE)
|
||||
.expand(result -> (result.nextCursor() != null) ?
|
||||
this.listRoots(result.nextCursor()) : Mono.empty())
|
||||
.reduce(new McpSchema.ListRootsResult(new ArrayList<>(), null),
|
||||
(allRootsResult, result) -> {
|
||||
allRootsResult.roots().addAll(result.roots());
|
||||
return allRootsResult;
|
||||
})
|
||||
.map(result -> new McpSchema.ListRootsResult(Collections.unmodifiableList(result.roots()),
|
||||
result.nextCursor()));
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the client. 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.session.isNotificationForLevelAllowed(loggingMessageNotification.level())) {
|
||||
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingMessageNotification);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification to the client that the current progress status has changed for
|
||||
* long-running operations.
|
||||
* @param progressNotification The progress notification to send
|
||||
* @return A Mono that completes when the notification has been sent
|
||||
*/
|
||||
public Mono<Void> progressNotification(McpSchema.ProgressNotification progressNotification) {
|
||||
if (progressNotification == null) {
|
||||
return Mono.error(new McpError("Progress notification must not be null"));
|
||||
}
|
||||
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_PROGRESS, progressNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a ping request to the client.
|
||||
* @return A Mono that completes with clients's ping response
|
||||
*/
|
||||
public Mono<Object> ping() {
|
||||
return this.session.sendRequest(McpSchema.METHOD_PING, null, OBJECT_TYPE_REF);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.session.setMinLoggingLevel(minLoggingLevel);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
* Copyright 2024-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Request handler for the initialization request.
|
||||
*/
|
||||
public interface McpInitRequestHandler {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue