feat: add Streamable HTTP Server abstractions and implement WebFlux transport provider (#420)
- Add WebFluxStreamableServerTransportProvider for streamable HTTP sessions - Add WebFluxStatelessServerTransport for stateless HTTP MCP servers - Add McpStatelessServerFeatures, McpStatelessServerHandler, McpStatelessRequestHandler, McpStatelessNotificationHandler - Refactor server architecture to support multiple transport categories: streamable, single-session, stateless - Introduce McpTransportContext for transport-level metadata extraction - Add session management capabilities for streamable HTTP connections - Update MCP protocol version to 2025-03-26 - Add test coverage for new transport implementations - Implement integration tests for both stateless and streamable transports Signed-off-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com> Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com> Co-authored-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
c3a0b1855a
commit
a8f5a3fb4f
53 changed files with 6578 additions and 704 deletions
|
|
@ -341,7 +341,8 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
McpSchema.JSONRPCResponse jsonRpcResponse = objectMapper.readValue(body,
|
||||
McpSchema.JSONRPCResponse.class);
|
||||
jsonRpcError = jsonRpcResponse.error();
|
||||
toPropagate = new McpError(jsonRpcError);
|
||||
toPropagate = jsonRpcError != null ? new McpError(jsonRpcError)
|
||||
: new McpError("Can't parse the jsonResponse " + jsonRpcResponse);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
toPropagate = new RuntimeException("Sending request failed", e);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.McpStatelessServerHandler;
|
||||
import io.modelcontextprotocol.server.DefaultMcpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
|
||||
import io.modelcontextprotocol.server.McpTransportContext;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Implementation of a WebFlux based {@link McpStatelessServerTransport}.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class WebFluxStatelessServerTransport implements McpStatelessServerTransport {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxStatelessServerTransport.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final String mcpEndpoint;
|
||||
|
||||
private final RouterFunction<?> routerFunction;
|
||||
|
||||
private McpStatelessServerHandler mcpHandler;
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor;
|
||||
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
private WebFluxStatelessServerTransport(ObjectMapper objectMapper, String mcpEndpoint,
|
||||
McpTransportContextExtractor<ServerRequest> contextExtractor) {
|
||||
Assert.notNull(objectMapper, "objectMapper must not be null");
|
||||
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
|
||||
Assert.notNull(contextExtractor, "contextExtractor must not be null");
|
||||
|
||||
this.objectMapper = objectMapper;
|
||||
this.mcpEndpoint = mcpEndpoint;
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.routerFunction = RouterFunctions.route()
|
||||
.GET(this.mcpEndpoint, this::handleGet)
|
||||
.POST(this.mcpEndpoint, this::handlePost)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
|
||||
this.mcpHandler = mcpHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(() -> this.isClosing = true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFlux router function that defines the transport's HTTP endpoints.
|
||||
* This router function should be integrated into the application's web configuration.
|
||||
*
|
||||
* <p>
|
||||
* The router function defines one endpoint handling two HTTP methods:
|
||||
* <ul>
|
||||
* <li>GET {messageEndpoint} - Unsupported, returns 405 METHOD NOT ALLOWED</li>
|
||||
* <li>POST {messageEndpoint} - For handling client requests and notifications</li>
|
||||
* </ul>
|
||||
* @return The configured {@link RouterFunction} for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<?> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> handleGet(ServerRequest request) {
|
||||
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> handlePost(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
|
||||
|
||||
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
|
||||
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
|
||||
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
|
||||
return ServerResponse.badRequest().build();
|
||||
}
|
||||
|
||||
return request.bodyToMono(String.class).<ServerResponse>flatMap(body -> {
|
||||
try {
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
|
||||
|
||||
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
|
||||
return this.mcpHandler.handleRequest(transportContext, jsonrpcRequest)
|
||||
.flatMap(jsonrpcResponse -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(jsonrpcResponse));
|
||||
}
|
||||
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
|
||||
return this.mcpHandler.handleNotification(transportContext, jsonrpcNotification)
|
||||
.then(ServerResponse.accepted().build());
|
||||
}
|
||||
else {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(new McpError("The server accepts either requests or notifications"));
|
||||
}
|
||||
}
|
||||
catch (IllegalArgumentException | IOException e) {
|
||||
logger.error("Failed to deserialize message: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
|
||||
}
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a builder for the server.
|
||||
* @return a fresh {@link Builder} instance.
|
||||
*/
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating instances of {@link WebFluxStatelessServerTransport}.
|
||||
* <p>
|
||||
* This builder provides a fluent API for configuring and creating instances of
|
||||
* WebFluxSseServerTransportProvider with custom settings.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private String mcpEndpoint = "/mcp";
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor = (serverRequest, context) -> context;
|
||||
|
||||
private Builder() {
|
||||
// used by a static method
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
|
||||
* messages.
|
||||
* @param objectMapper The ObjectMapper instance. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if objectMapper is null
|
||||
*/
|
||||
public Builder objectMapper(ObjectMapper objectMapper) {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||
this.objectMapper = objectMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint URI where clients should send their JSON-RPC messages.
|
||||
* @param messageEndpoint The message endpoint URI. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if messageEndpoint is null
|
||||
*/
|
||||
public Builder messageEndpoint(String messageEndpoint) {
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
this.mcpEndpoint = messageEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context extractor that allows providing the MCP feature
|
||||
* implementations to inspect HTTP transport level metadata that was present at
|
||||
* HTTP request processing time. This allows to extract custom headers and other
|
||||
* useful data for use during execution later on in the process.
|
||||
* @param contextExtractor The contextExtractor to fill in a
|
||||
* {@link McpTransportContext}.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if contextExtractor is null
|
||||
*/
|
||||
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
|
||||
Assert.notNull(contextExtractor, "Context extractor must not be null");
|
||||
this.contextExtractor = contextExtractor;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new instance of {@link WebFluxStatelessServerTransport} with the
|
||||
* configured settings.
|
||||
* @return A new WebFluxSseServerTransportProvider instance
|
||||
* @throws IllegalStateException if required parameters are not set
|
||||
*/
|
||||
public WebFluxStatelessServerTransport build() {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must be set");
|
||||
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
|
||||
|
||||
return new WebFluxStatelessServerTransport(objectMapper, mcpEndpoint, contextExtractor);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.DefaultMcpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerSession;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.McpTransportContext;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.Disposable;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Implementation of a WebFlux based {@link McpStreamableServerTransportProvider}.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class WebFluxStreamableServerTransportProvider implements McpStreamableServerTransportProvider {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxStreamableServerTransportProvider.class);
|
||||
|
||||
public static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final String mcpEndpoint;
|
||||
|
||||
private final boolean disallowDelete;
|
||||
|
||||
private final RouterFunction<?> routerFunction;
|
||||
|
||||
private McpStreamableServerSession.Factory sessionFactory;
|
||||
|
||||
private final ConcurrentHashMap<String, McpStreamableServerSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor;
|
||||
|
||||
private volatile boolean isClosing = false;
|
||||
|
||||
private WebFluxStreamableServerTransportProvider(ObjectMapper objectMapper, String mcpEndpoint,
|
||||
McpTransportContextExtractor<ServerRequest> contextExtractor, boolean disallowDelete) {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||
Assert.notNull(mcpEndpoint, "Message endpoint must not be null");
|
||||
Assert.notNull(contextExtractor, "Context extractor must not be null");
|
||||
|
||||
this.objectMapper = objectMapper;
|
||||
this.mcpEndpoint = mcpEndpoint;
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.disallowDelete = disallowDelete;
|
||||
this.routerFunction = RouterFunctions.route()
|
||||
.GET(this.mcpEndpoint, this::handleGet)
|
||||
.POST(this.mcpEndpoint, this::handlePost)
|
||||
.DELETE(this.mcpEndpoint, this::handleDelete)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) {
|
||||
this.sessionFactory = sessionFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> notifyClients(String method, Object params) {
|
||||
if (sessions.isEmpty()) {
|
||||
logger.debug("No active sessions to broadcast message to");
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
logger.debug("Attempting to broadcast message to {} active sessions", sessions.size());
|
||||
|
||||
return Flux.fromIterable(sessions.values())
|
||||
.flatMap(session -> session.sendNotification(method, params)
|
||||
.doOnError(
|
||||
e -> logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()))
|
||||
.onErrorComplete())
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.defer(() -> {
|
||||
this.isClosing = true;
|
||||
return Flux.fromIterable(sessions.values())
|
||||
.doFirst(() -> logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()))
|
||||
.flatMap(McpStreamableServerSession::closeGracefully)
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WebFlux router function that defines the transport's HTTP endpoints.
|
||||
* This router function should be integrated into the application's web configuration.
|
||||
*
|
||||
* <p>
|
||||
* The router function defines one endpoint with three methods:
|
||||
* <ul>
|
||||
* <li>GET {messageEndpoint} - For the client listening SSE stream</li>
|
||||
* <li>POST {messageEndpoint} - For receiving client messages</li>
|
||||
* <li>DELETE {messageEndpoint} - For removing sessions</li>
|
||||
* </ul>
|
||||
* @return The configured {@link RouterFunction} for handling HTTP requests
|
||||
*/
|
||||
public RouterFunction<?> getRouterFunction() {
|
||||
return this.routerFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the listening SSE streams for clients.
|
||||
* @param request The incoming server request
|
||||
* @return A Mono which emits a response with the SSE event stream
|
||||
*/
|
||||
private Mono<ServerResponse> handleGet(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
|
||||
|
||||
return Mono.defer(() -> {
|
||||
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
|
||||
if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) {
|
||||
return ServerResponse.badRequest().build();
|
||||
}
|
||||
|
||||
if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) {
|
||||
return ServerResponse.badRequest().build(); // TODO: say we need a session
|
||||
// id
|
||||
}
|
||||
|
||||
String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
|
||||
McpStreamableServerSession session = this.sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
|
||||
if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) {
|
||||
String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID);
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(session.replay(lastId), ServerSentEvent.class);
|
||||
}
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(Flux.<ServerSentEvent<?>>create(sink -> {
|
||||
WebFluxStreamableMcpSessionTransport sessionTransport = new WebFluxStreamableMcpSessionTransport(
|
||||
sink);
|
||||
McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session
|
||||
.listeningStream(sessionTransport);
|
||||
sink.onDispose(listeningStream::close);
|
||||
}), ServerSentEvent.class);
|
||||
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients.
|
||||
* @param request The incoming server request containing the JSON-RPC message
|
||||
* @return A Mono with the response appropriate to a particular Streamable HTTP flow.
|
||||
*/
|
||||
private Mono<ServerResponse> handlePost(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
|
||||
|
||||
List<MediaType> acceptHeaders = request.headers().asHttpHeaders().getAccept();
|
||||
if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON)
|
||||
&& acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) {
|
||||
return ServerResponse.badRequest().build();
|
||||
}
|
||||
|
||||
return request.bodyToMono(String.class).<ServerResponse>flatMap(body -> {
|
||||
try {
|
||||
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body);
|
||||
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest
|
||||
&& jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) {
|
||||
McpSchema.InitializeRequest initializeRequest = objectMapper.convertValue(jsonrpcRequest.params(),
|
||||
new TypeReference<McpSchema.InitializeRequest>() {
|
||||
});
|
||||
McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory
|
||||
.startSession(initializeRequest);
|
||||
sessions.put(init.session().getId(), init.session());
|
||||
return init.initResult().map(initializeResult -> {
|
||||
McpSchema.JSONRPCResponse jsonrpcResponse = new McpSchema.JSONRPCResponse(
|
||||
McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initializeResult, null);
|
||||
try {
|
||||
return this.objectMapper.writeValueAsString(jsonrpcResponse);
|
||||
}
|
||||
catch (IOException e) {
|
||||
logger.warn("Failed to serialize initResponse", e);
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
})
|
||||
.flatMap(initResult -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header(HttpHeaders.MCP_SESSION_ID, init.session().getId())
|
||||
.bodyValue(initResult));
|
||||
}
|
||||
|
||||
if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) {
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Session ID missing"));
|
||||
}
|
||||
|
||||
String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
McpStreamableServerSession session = sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.status(HttpStatus.NOT_FOUND)
|
||||
.bodyValue(new McpError("Session not found: " + sessionId));
|
||||
}
|
||||
|
||||
if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) {
|
||||
return session.accept(jsonrpcResponse).then(ServerResponse.accepted().build());
|
||||
}
|
||||
else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
|
||||
return session.accept(jsonrpcNotification).then(ServerResponse.accepted().build());
|
||||
}
|
||||
else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.TEXT_EVENT_STREAM)
|
||||
.body(Flux.<ServerSentEvent<?>>create(sink -> {
|
||||
WebFluxStreamableMcpSessionTransport st = new WebFluxStreamableMcpSessionTransport(sink);
|
||||
Mono<Void> stream = session.responseStream(jsonrpcRequest, st);
|
||||
Disposable streamSubscription = stream.onErrorComplete(err -> {
|
||||
sink.error(err);
|
||||
return true;
|
||||
}).contextWrite(sink.contextView()).subscribe();
|
||||
sink.onCancel(streamSubscription);
|
||||
}), ServerSentEvent.class);
|
||||
}
|
||||
else {
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Unknown message type"));
|
||||
}
|
||||
}
|
||||
catch (IllegalArgumentException | IOException e) {
|
||||
logger.error("Failed to deserialize message: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(new McpError("Invalid message format"));
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.badRequest().build())
|
||||
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> handleDelete(ServerRequest request) {
|
||||
if (isClosing) {
|
||||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down");
|
||||
}
|
||||
|
||||
McpTransportContext transportContext = this.contextExtractor.extract(request, new DefaultMcpTransportContext());
|
||||
|
||||
return Mono.defer(() -> {
|
||||
if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) {
|
||||
return ServerResponse.badRequest().build(); // TODO: say we need a session
|
||||
// id
|
||||
}
|
||||
|
||||
if (this.disallowDelete) {
|
||||
return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build();
|
||||
}
|
||||
|
||||
String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
|
||||
McpStreamableServerSession session = this.sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
return ServerResponse.notFound().build();
|
||||
}
|
||||
|
||||
return session.delete().then(ServerResponse.ok().build());
|
||||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext));
|
||||
}
|
||||
|
||||
private class WebFluxStreamableMcpSessionTransport implements McpStreamableServerTransport {
|
||||
|
||||
private final FluxSink<ServerSentEvent<?>> sink;
|
||||
|
||||
public WebFluxStreamableMcpSessionTransport(FluxSink<ServerSentEvent<?>> sink) {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return this.sendMessage(message, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message, String messageId) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(message);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw Exceptions.propagate(e);
|
||||
}
|
||||
}).doOnNext(jsonText -> {
|
||||
ServerSentEvent<Object> event = ServerSentEvent.builder()
|
||||
.id(messageId)
|
||||
.event(MESSAGE_EVENT_TYPE)
|
||||
.data(jsonText)
|
||||
.build();
|
||||
sink.next(event);
|
||||
}).doOnError(e -> {
|
||||
// TODO log with sessionid
|
||||
Throwable exception = Exceptions.unwrap(e);
|
||||
sink.error(exception);
|
||||
}).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeReference<T> typeRef) {
|
||||
return objectMapper.convertValue(data, typeRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.fromRunnable(sink::complete);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
sink.complete();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating instances of {@link WebFluxStreamableServerTransportProvider}.
|
||||
* <p>
|
||||
* This builder provides a fluent API for configuring and creating instances of
|
||||
* WebFluxStreamableServerTransportProvider with custom settings.
|
||||
*/
|
||||
public static class Builder {
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private String mcpEndpoint = "/mcp";
|
||||
|
||||
private McpTransportContextExtractor<ServerRequest> contextExtractor = (serverRequest, context) -> context;
|
||||
|
||||
private boolean disallowDelete;
|
||||
|
||||
private Builder() {
|
||||
// used by a static method
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
|
||||
* messages.
|
||||
* @param objectMapper The ObjectMapper instance. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if objectMapper is null
|
||||
*/
|
||||
public Builder objectMapper(ObjectMapper objectMapper) {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must not be null");
|
||||
this.objectMapper = objectMapper;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the endpoint URI where clients should send their JSON-RPC messages.
|
||||
* @param messageEndpoint The message endpoint URI. Must not be null.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if messageEndpoint is null
|
||||
*/
|
||||
public Builder messageEndpoint(String messageEndpoint) {
|
||||
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
|
||||
this.mcpEndpoint = messageEndpoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the context extractor that allows providing the MCP feature
|
||||
* implementations to inspect HTTP transport level metadata that was present at
|
||||
* HTTP request processing time. This allows to extract custom headers and other
|
||||
* useful data for use during execution later on in the process.
|
||||
* @param contextExtractor The contextExtractor to fill in a
|
||||
* {@link McpTransportContext}.
|
||||
* @return this builder instance
|
||||
* @throws IllegalArgumentException if contextExtractor is null
|
||||
*/
|
||||
public Builder contextExtractor(McpTransportContextExtractor<ServerRequest> contextExtractor) {
|
||||
Assert.notNull(contextExtractor, "contextExtractor must not be null");
|
||||
this.contextExtractor = contextExtractor;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the session removal capability is disabled.
|
||||
* @param disallowDelete if {@code true}, the DELETE endpoint will not be
|
||||
* supported and sessions won't be deleted.
|
||||
* @return this builder instance
|
||||
*/
|
||||
public Builder disallowDelete(boolean disallowDelete) {
|
||||
this.disallowDelete = disallowDelete;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a new instance of {@link WebFluxStreamableServerTransportProvider} with
|
||||
* the configured settings.
|
||||
* @return A new WebFluxStreamableServerTransportProvider instance
|
||||
* @throws IllegalStateException if required parameters are not set
|
||||
*/
|
||||
public WebFluxStreamableServerTransportProvider build() {
|
||||
Assert.notNull(objectMapper, "ObjectMapper must be set");
|
||||
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
|
||||
|
||||
return new WebFluxStreamableServerTransportProvider(objectMapper, mcpEndpoint, contextExtractor,
|
||||
disallowDelete);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
package io.modelcontextprotocol;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Prompt;
|
||||
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
|
||||
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import io.modelcontextprotocol.server.McpTransportContext;
|
||||
import net.javacrumbs.jsonunit.core.Option;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
|
||||
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
class WebFluxStatelessIntegrationTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private WebFluxStatelessServerTransport mcpStreamableServerTransport;
|
||||
|
||||
ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
this.mcpStreamableServerTransport = WebFluxStatelessServerTransport.builder()
|
||||
.objectMapper(new ObjectMapper())
|
||||
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build();
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStreamableServerTransport.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
|
||||
clientBuilders
|
||||
.put("httpclient",
|
||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||
.endpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
|
||||
clientBuilders
|
||||
.put("webflux", McpClient
|
||||
.sync(WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
||||
.endpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||
.build())
|
||||
.initializationTimeout(Duration.ofHours(10))
|
||||
.requestTimeout(Duration.ofHours(10)));
|
||||
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tools Tests
|
||||
// ---------------------------------------
|
||||
|
||||
String emptyJsonSchema = """
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
""";
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testToolCallSuccess(String clientType) {
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
||||
McpStatelessServerFeatures.SyncToolSpecification tool1 = new McpStatelessServerFeatures.SyncToolSpecification(
|
||||
new Tool("tool1", "tool1 description", emptyJsonSchema), (transportContext, request) -> {
|
||||
// perform a blocking call to a remote service
|
||||
String response = RestClient.create()
|
||||
.get()
|
||||
.uri("https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
assertThat(response).isNotBlank();
|
||||
return callResponse;
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool1)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
|
||||
|
||||
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response).isEqualTo(callResponse);
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testInitialize(String clientType) {
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport).build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Completion Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : Completion call")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCompletionShouldReturnExpectedSuggestions(String clientType) {
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
var expectedValues = List.of("python", "pytorch", "pyside");
|
||||
var completionResponse = new CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
|
||||
true // hasMore
|
||||
));
|
||||
|
||||
AtomicReference<CompleteRequest> samplingRequest = new AtomicReference<>();
|
||||
BiFunction<McpTransportContext, CompleteRequest, CompleteResult> completionHandler = (transportContext,
|
||||
request) -> {
|
||||
samplingRequest.set(request);
|
||||
return completionResponse;
|
||||
};
|
||||
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
||||
.capabilities(ServerCapabilities.builder().completions().build())
|
||||
.prompts(new McpStatelessServerFeatures.SyncPromptSpecification(
|
||||
new Prompt("code_review", "Code review", "this is code review prompt",
|
||||
List.of(new PromptArgument("language", "Language", "string", false))),
|
||||
(transportContext, getPromptRequest) -> null))
|
||||
.completions(new McpStatelessServerFeatures.SyncCompletionSpecification(
|
||||
new PromptReference("ref/prompt", "code_review", "Code review"), completionHandler))
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
CompleteRequest request = new CompleteRequest(
|
||||
new PromptReference("ref/prompt", "code_review", "Code review"),
|
||||
new CompleteRequest.CompleteArgument("language", "py"));
|
||||
|
||||
CompleteResult result = mcpClient.completeCompletion(request);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
|
||||
assertThat(samplingRequest.get().argument().name()).isEqualTo("language");
|
||||
assertThat(samplingRequest.get().argument().value()).isEqualTo("py");
|
||||
assertThat(samplingRequest.get().ref().type()).isEqualTo("ref/prompt");
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Tool Structured Output Schema Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testStructuredOutputValidationSuccess(String clientType) {
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
// Create a tool with output schema
|
||||
Map<String, Object> outputSchema = Map.of(
|
||||
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
|
||||
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
|
||||
"required", List.of("result", "operation"));
|
||||
|
||||
Tool calculatorTool = Tool.builder()
|
||||
.name("calculator")
|
||||
.description("Performs mathematical calculations")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
|
||||
calculatorTool, (transportContext, request) -> {
|
||||
String expression = (String) request.arguments().getOrDefault("expression", "2 + 3");
|
||||
double result = evaluateExpression(expression);
|
||||
return CallToolResult.builder()
|
||||
.structuredContent(
|
||||
Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
|
||||
.build();
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Verify tool is listed with output schema
|
||||
var toolsList = mcpClient.listTools();
|
||||
assertThat(toolsList.tools()).hasSize(1);
|
||||
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
|
||||
// Note: outputSchema might be null in sync server, but validation still works
|
||||
|
||||
// Call tool with valid structured output
|
||||
CallToolResult response = mcpClient
|
||||
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.isError()).isFalse();
|
||||
assertThat(response.content()).hasSize(1);
|
||||
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
|
||||
assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).when(Option.IGNORING_ARRAY_ORDER)
|
||||
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
|
||||
.isObject()
|
||||
.isEqualTo(json("""
|
||||
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
|
||||
|
||||
assertThat(response.structuredContent()).isNotNull();
|
||||
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
|
||||
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
|
||||
.isObject()
|
||||
.isEqualTo(json("""
|
||||
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testStructuredOutputValidationFailure(String clientType) {
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
// Create a tool with output schema
|
||||
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
|
||||
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
|
||||
List.of("result", "operation"));
|
||||
|
||||
Tool calculatorTool = Tool.builder()
|
||||
.name("calculator")
|
||||
.description("Performs mathematical calculations")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
|
||||
calculatorTool, (transportContext, request) -> {
|
||||
// Return invalid structured output. Result should be number, missing
|
||||
// operation
|
||||
return CallToolResult.builder()
|
||||
.addTextContent("Invalid calculation")
|
||||
.structuredContent(Map.of("result", "not-a-number", "extra", "field"))
|
||||
.build();
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Call tool with invalid structured output
|
||||
CallToolResult response = mcpClient
|
||||
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.isError()).isTrue();
|
||||
assertThat(response.content()).hasSize(1);
|
||||
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
|
||||
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
|
||||
assertThat(errorMessage).contains("Validation failed");
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testStructuredOutputMissingStructuredContent(String clientType) {
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
// Create a tool with output schema
|
||||
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
|
||||
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
|
||||
|
||||
Tool calculatorTool = Tool.builder()
|
||||
.name("calculator")
|
||||
.description("Performs mathematical calculations")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
|
||||
calculatorTool, (transportContext, request) -> {
|
||||
// Return result without structured content but tool has output schema
|
||||
return CallToolResult.builder().addTextContent("Calculation completed").build();
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.instructions("bla")
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Call tool that should return structured content but doesn't
|
||||
CallToolResult response = mcpClient
|
||||
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.isError()).isTrue();
|
||||
assertThat(response.content()).hasSize(1);
|
||||
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
|
||||
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
|
||||
assertThat(errorMessage).isEqualTo(
|
||||
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testStructuredOutputRuntimeToolAddition(String clientType) {
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
// Start server without tools
|
||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.build()) {
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
// Initially no tools
|
||||
assertThat(mcpClient.listTools().tools()).isEmpty();
|
||||
|
||||
// Add tool with output schema at runtime
|
||||
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
|
||||
Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
|
||||
List.of("message", "count"));
|
||||
|
||||
Tool dynamicTool = Tool.builder()
|
||||
.name("dynamic-tool")
|
||||
.description("Dynamically added tool")
|
||||
.outputSchema(outputSchema)
|
||||
.build();
|
||||
|
||||
McpStatelessServerFeatures.SyncToolSpecification toolSpec = new McpStatelessServerFeatures.SyncToolSpecification(
|
||||
dynamicTool, (transportContext, request) -> {
|
||||
int count = (Integer) request.arguments().getOrDefault("count", 1);
|
||||
return CallToolResult.builder()
|
||||
.addTextContent("Dynamic tool executed " + count + " times")
|
||||
.structuredContent(Map.of("message", "Dynamic execution", "count", count))
|
||||
.build();
|
||||
});
|
||||
|
||||
// Add tool to server
|
||||
mcpServer.addTool(toolSpec);
|
||||
|
||||
// Wait for tool list change notification
|
||||
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
||||
assertThat(mcpClient.listTools().tools()).hasSize(1);
|
||||
});
|
||||
|
||||
// Verify tool was added with output schema
|
||||
var toolsList = mcpClient.listTools();
|
||||
assertThat(toolsList.tools()).hasSize(1);
|
||||
assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
|
||||
// Note: outputSchema might be null in sync server, but validation still works
|
||||
|
||||
// Call dynamically added tool
|
||||
CallToolResult response = mcpClient
|
||||
.callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response.isError()).isFalse();
|
||||
assertThat(response.content()).hasSize(1);
|
||||
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
||||
assertThat(((McpSchema.TextContent) response.content().get(0)).text())
|
||||
.isEqualTo("Dynamic tool executed 3 times");
|
||||
|
||||
assertThat(response.structuredContent()).isNotNull();
|
||||
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
|
||||
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
|
||||
.isObject()
|
||||
.isEqualTo(json("""
|
||||
{"count":3,"message":"Dynamic execution"}"""));
|
||||
}
|
||||
|
||||
mcpServer.close();
|
||||
}
|
||||
|
||||
private double evaluateExpression(String expression) {
|
||||
// Simple expression evaluator for testing
|
||||
return switch (expression) {
|
||||
case "2 + 3" -> 5.0;
|
||||
case "10 * 2" -> 20.0;
|
||||
case "7 + 8" -> 15.0;
|
||||
case "5 + 3" -> 8.0;
|
||||
default -> 0.0;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -29,8 +29,7 @@ class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
|
|||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
@Override
|
||||
protected McpServerTransportProvider createMcpTransportProvider() {
|
||||
private McpServerTransportProvider createMcpTransportProvider() {
|
||||
var transportProvider = new WebFluxSseServerTransportProvider.Builder().objectMapper(new ObjectMapper())
|
||||
.messageEndpoint(MESSAGE_ENDPOINT)
|
||||
.build();
|
||||
|
|
@ -41,6 +40,11 @@ class WebFluxSseMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
|
|||
return transportProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||
return McpServer.async(createMcpTransportProvider());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,11 @@ class WebFluxSseMcpSyncServerTests extends AbstractMcpSyncServerTests {
|
|||
private WebFluxSseServerTransportProvider transportProvider;
|
||||
|
||||
@Override
|
||||
protected McpServerTransportProvider createMcpTransportProvider() {
|
||||
protected McpServer.SyncSpecification<?> prepareSyncServerBuilder() {
|
||||
return McpServer.sync(createMcpTransportProvider());
|
||||
}
|
||||
|
||||
private McpServerTransportProvider createMcpTransportProvider() {
|
||||
transportProvider = new WebFluxSseServerTransportProvider.Builder().objectMapper(new ObjectMapper())
|
||||
.messageEndpoint(MESSAGE_ENDPOINT)
|
||||
.build();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
/**
|
||||
* Tests for {@link McpAsyncServer} using
|
||||
* {@link WebFluxStreamableServerTransportProvider}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
@Timeout(15) // Giving extra time beyond the client timeout
|
||||
class WebFluxStreamableMcpAsyncServerTests extends AbstractMcpAsyncServerTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private McpStreamableServerTransportProvider createMcpTransportProvider() {
|
||||
var transportProvider = WebFluxStreamableServerTransportProvider.builder()
|
||||
.objectMapper(new ObjectMapper())
|
||||
.messageEndpoint(MESSAGE_ENDPOINT)
|
||||
.build();
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
return transportProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||
return McpServer.async(createMcpTransportProvider());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClose() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
/**
|
||||
* Tests for {@link McpAsyncServer} using
|
||||
* {@link WebFluxStreamableServerTransportProvider}.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
@Timeout(15) // Giving extra time beyond the client timeout
|
||||
class WebFluxStreamableMcpSyncServerTests extends AbstractMcpSyncServerTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private DisposableServer httpServer;
|
||||
|
||||
private McpStreamableServerTransportProvider createMcpTransportProvider() {
|
||||
var transportProvider = WebFluxStreamableServerTransportProvider.builder()
|
||||
.objectMapper(new ObjectMapper())
|
||||
.messageEndpoint(MESSAGE_ENDPOINT)
|
||||
.build();
|
||||
|
||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(transportProvider.getRouterFunction());
|
||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||
httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||
return transportProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected McpServer.SyncSpecification<?> prepareSyncServerBuilder() {
|
||||
return McpServer.sync(createMcpTransportProvider());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onClose() {
|
||||
if (httpServer != null) {
|
||||
httpServer.disposeNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -49,8 +49,7 @@ class WebMvcSseAsyncServerTransportTests extends AbstractMcpAsyncServerTests {
|
|||
|
||||
private AnnotationConfigWebApplicationContext appContext;
|
||||
|
||||
@Override
|
||||
protected McpServerTransportProvider createMcpTransportProvider() {
|
||||
private McpServerTransportProvider createMcpTransportProvider() {
|
||||
// Set up Tomcat first
|
||||
tomcat = new Tomcat();
|
||||
tomcat.setPort(PORT);
|
||||
|
|
@ -90,6 +89,11 @@ class WebMvcSseAsyncServerTransportTests extends AbstractMcpAsyncServerTests {
|
|||
return transportProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||
return McpServer.async(createMcpTransportProvider());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ class WebMvcSseSyncServerTransportTests extends AbstractMcpSyncServerTests {
|
|||
private AnnotationConfigWebApplicationContext appContext;
|
||||
|
||||
@Override
|
||||
protected WebMvcSseServerTransportProvider createMcpTransportProvider() {
|
||||
protected McpServer.SyncSpecification<?> prepareSyncServerBuilder() {
|
||||
return McpServer.sync(createMcpTransportProvider());
|
||||
}
|
||||
|
||||
private WebMvcSseServerTransportProvider createMcpTransportProvider() {
|
||||
// Set up Tomcat first
|
||||
tomcat = new Tomcat();
|
||||
tomcat.setPort(PORT);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
|||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
|
|
@ -42,7 +44,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
private static final String TEST_PROMPT_NAME = "test-prompt";
|
||||
|
||||
abstract protected McpServerTransportProvider createMcpTransportProvider();
|
||||
abstract protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder();
|
||||
|
||||
protected void onStart() {
|
||||
}
|
||||
|
|
@ -63,28 +65,29 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
// Server Lifecycle Tests
|
||||
// ---------------------------------------
|
||||
|
||||
@Test
|
||||
void testConstructorWithInvalidArguments() {
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "sse", "streamable" })
|
||||
void testConstructorWithInvalidArguments(String serverType) {
|
||||
assertThatThrownBy(() -> McpServer.async((McpServerTransportProvider) null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Transport provider must not be null");
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> McpServer.async(createMcpTransportProvider()).serverInfo((McpSchema.Implementation) null))
|
||||
assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo((McpSchema.Implementation) null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Server info must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGracefulShutdown() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
McpServer.AsyncSpecification<?> builder = prepareAsyncServerBuilder();
|
||||
var mcpAsyncServer = builder.serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImmediateClose() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpAsyncServer.close()).doesNotThrowAnyException();
|
||||
}
|
||||
|
|
@ -104,8 +107,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
@Deprecated
|
||||
void testAddTool() {
|
||||
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -119,8 +121,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
@Test
|
||||
void testAddToolCall() {
|
||||
Tool newTool = new McpSchema.Tool("new-tool", "New test tool", emptyJsonSchema);
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -137,8 +138,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
void testAddDuplicateTool() {
|
||||
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(duplicateTool, (exchange, args) -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
|
@ -158,8 +158,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
void testAddDuplicateToolCall() {
|
||||
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(duplicateTool, (exchange, request) -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
|
@ -180,8 +179,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
Tool duplicateTool = new Tool("duplicate-build-toolcall", "Duplicate toolcall during building",
|
||||
emptyJsonSchema);
|
||||
|
||||
assertThatThrownBy(() -> McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(duplicateTool, (exchange, request) -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.toolCall(duplicateTool, (exchange, request) -> Mono.just(new CallToolResult(List.of(), false))) // Duplicate!
|
||||
|
|
@ -203,8 +201,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
.build() // Duplicate!
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(specs)
|
||||
.build()).isInstanceOf(IllegalArgumentException.class)
|
||||
|
|
@ -215,8 +212,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
void testDuplicateToolsInBatchVarargsRegistration() {
|
||||
Tool duplicateTool = new Tool("batch-varargs-tool", "Duplicate tool in batch varargs", emptyJsonSchema);
|
||||
|
||||
assertThatThrownBy(() -> McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
assertThatThrownBy(() -> prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(McpServerFeatures.AsyncToolSpecification.builder()
|
||||
.tool(duplicateTool)
|
||||
|
|
@ -235,8 +231,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
void testRemoveTool() {
|
||||
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(too, (exchange, request) -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
|
@ -248,8 +243,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemoveNonexistentTool() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -264,8 +258,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
void testNotifyToolsListChanged() {
|
||||
Tool too = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(too, (exchange, args) -> Mono.just(new CallToolResult(List.of(), false)))
|
||||
.build();
|
||||
|
|
@ -281,7 +274,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyResourcesListChanged() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.notifyResourcesListChanged()).verifyComplete();
|
||||
|
||||
|
|
@ -290,7 +283,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyResourcesUpdated() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier
|
||||
.create(mcpAsyncServer
|
||||
|
|
@ -302,8 +295,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddResource() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -319,8 +311,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddResourceWithNullSpecification() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -335,9 +326,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
@Test
|
||||
void testAddResourceWithoutCapability() {
|
||||
// Create a server without resource capabilities
|
||||
McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
|
||||
null);
|
||||
|
|
@ -353,9 +342,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
@Test
|
||||
void testRemoveResourceWithoutCapability() {
|
||||
// Create a server without resource capabilities
|
||||
McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
McpAsyncServer serverWithoutResources = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(serverWithoutResources.removeResource(TEST_RESOURCE_URI)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
|
|
@ -369,7 +356,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyPromptsListChanged() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(mcpAsyncServer.notifyPromptsListChanged()).verifyComplete();
|
||||
|
||||
|
|
@ -378,8 +365,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddPromptWithNullSpecification() {
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -392,9 +378,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
@Test
|
||||
void testAddPromptWithoutCapability() {
|
||||
// Create a server without prompt capabilities
|
||||
McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
McpAsyncServer serverWithoutPrompts = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of());
|
||||
McpServerFeatures.AsyncPromptSpecification specification = new McpServerFeatures.AsyncPromptSpecification(
|
||||
|
|
@ -410,9 +394,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
@Test
|
||||
void testRemovePromptWithoutCapability() {
|
||||
// Create a server without prompt capabilities
|
||||
McpAsyncServer serverWithoutPrompts = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
McpAsyncServer serverWithoutPrompts = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
StepVerifier.create(serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).verifyErrorSatisfies(error -> {
|
||||
assertThat(error).isInstanceOf(McpError.class)
|
||||
|
|
@ -429,8 +411,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
prompt, (exchange, req) -> Mono.just(new GetPromptResult("Test prompt description", List
|
||||
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content"))))));
|
||||
|
||||
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.prompts(specification)
|
||||
.build();
|
||||
|
|
@ -442,8 +423,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemoveNonexistentPrompt() {
|
||||
var mcpAsyncServer2 = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpAsyncServer2 = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -466,8 +446,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
var rootsReceived = new McpSchema.Root[1];
|
||||
var consumerCalled = new boolean[1];
|
||||
|
||||
var singleConsumerServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var singleConsumerServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> {
|
||||
consumerCalled[0] = true;
|
||||
if (!roots.isEmpty()) {
|
||||
|
|
@ -486,8 +465,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
var consumer2Called = new boolean[1];
|
||||
var rootsContent = new List[1];
|
||||
|
||||
var multipleConsumersServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var multipleConsumersServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeHandlers(List.of((exchange, roots) -> Mono.fromRunnable(() -> {
|
||||
consumer1Called[0] = true;
|
||||
rootsContent[0] = roots;
|
||||
|
|
@ -500,8 +478,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
onClose();
|
||||
|
||||
// Test error handling
|
||||
var errorHandlingServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var errorHandlingServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeHandlers(List.of((exchange, roots) -> {
|
||||
throw new RuntimeException("Test error");
|
||||
}))
|
||||
|
|
@ -513,9 +490,7 @@ public abstract class AbstractMcpAsyncServerTests {
|
|||
onClose();
|
||||
|
||||
// Test without consumers
|
||||
var noConsumersServer = McpServer.async(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
var noConsumersServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThat(noConsumersServer).isNotNull();
|
||||
assertThatCode(() -> noConsumersServer.closeGracefully().block(Duration.ofSeconds(10)))
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
|||
|
||||
/**
|
||||
* Test suite for the {@link McpSyncServer} that can be used with different
|
||||
* {@link McpTransportProvider} implementations.
|
||||
* {@link McpServerTransportProvider} implementations.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
|
|
@ -40,7 +40,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
private static final String TEST_PROMPT_NAME = "test-prompt";
|
||||
|
||||
abstract protected McpServerTransportProvider createMcpTransportProvider();
|
||||
abstract protected McpServer.SyncSpecification<?> prepareSyncServerBuilder();
|
||||
|
||||
protected void onStart() {
|
||||
}
|
||||
|
|
@ -68,28 +68,28 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Transport provider must not be null");
|
||||
|
||||
assertThatThrownBy(() -> McpServer.sync(createMcpTransportProvider()).serverInfo(null))
|
||||
assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Server info must not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGracefulShutdown() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testImmediateClose() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.close()).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetAsyncServer() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThat(mcpSyncServer.getAsyncServer()).isNotNull();
|
||||
|
||||
|
|
@ -111,8 +111,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
@Test
|
||||
@Deprecated
|
||||
void testAddTool() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -126,8 +125,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddToolCall() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -145,8 +143,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
void testAddDuplicateTool() {
|
||||
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tool(duplicateTool, (exchange, args) -> new CallToolResult(List.of(), false))
|
||||
.build();
|
||||
|
|
@ -163,8 +160,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
void testAddDuplicateToolCall() {
|
||||
Tool duplicateTool = new McpSchema.Tool(TEST_TOOL_NAME, "Duplicate tool", emptyJsonSchema);
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(duplicateTool, (exchange, request) -> new CallToolResult(List.of(), false))
|
||||
.build();
|
||||
|
|
@ -183,8 +179,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
Tool duplicateTool = new Tool("duplicate-build-toolcall", "Duplicate toolcall during building",
|
||||
emptyJsonSchema);
|
||||
|
||||
assertThatThrownBy(() -> McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(duplicateTool, (exchange, request) -> new CallToolResult(List.of(), false))
|
||||
.toolCall(duplicateTool, (exchange, request) -> new CallToolResult(List.of(), false)) // Duplicate!
|
||||
|
|
@ -206,8 +201,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
.build() // Duplicate!
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(specs)
|
||||
.build()).isInstanceOf(IllegalArgumentException.class)
|
||||
|
|
@ -218,8 +212,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
void testDuplicateToolsInBatchVarargsRegistration() {
|
||||
Tool duplicateTool = new Tool("batch-varargs-tool", "Duplicate tool in batch varargs", emptyJsonSchema);
|
||||
|
||||
assertThatThrownBy(() -> McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
assertThatThrownBy(() -> prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.tools(McpServerFeatures.SyncToolSpecification.builder()
|
||||
.tool(duplicateTool)
|
||||
|
|
@ -238,8 +231,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
void testRemoveTool() {
|
||||
Tool tool = new McpSchema.Tool(TEST_TOOL_NAME, "Test tool", emptyJsonSchema);
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.toolCall(tool, (exchange, args) -> new CallToolResult(List.of(), false))
|
||||
.build();
|
||||
|
|
@ -251,8 +243,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemoveNonexistentTool() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -264,7 +255,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyToolsListChanged() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.notifyToolsListChanged()).doesNotThrowAnyException();
|
||||
|
||||
|
|
@ -277,7 +268,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyResourcesListChanged() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.notifyResourcesListChanged()).doesNotThrowAnyException();
|
||||
|
||||
|
|
@ -286,7 +277,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyResourcesUpdated() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer
|
||||
.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(TEST_RESOURCE_URI)))
|
||||
|
|
@ -297,8 +288,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddResource() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -314,8 +304,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddResourceWithNullSpecification() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().resources(true, false).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -328,9 +317,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddResourceWithoutCapability() {
|
||||
var serverWithoutResources = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Resource resource = new Resource(TEST_RESOURCE_URI, "Test Resource", "text/plain", "Test resource description",
|
||||
null);
|
||||
|
|
@ -343,9 +330,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemoveResourceWithoutCapability() {
|
||||
var serverWithoutResources = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
var serverWithoutResources = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatThrownBy(() -> serverWithoutResources.removeResource(TEST_RESOURCE_URI)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with resource capabilities");
|
||||
|
|
@ -357,7 +342,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testNotifyPromptsListChanged() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatCode(() -> mcpSyncServer.notifyPromptsListChanged()).doesNotThrowAnyException();
|
||||
|
||||
|
|
@ -366,8 +351,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddPromptWithNullSpecification() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(false).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -378,9 +362,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testAddPromptWithoutCapability() {
|
||||
var serverWithoutPrompts = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
Prompt prompt = new Prompt(TEST_PROMPT_NAME, "Test Prompt", "Test Prompt", List.of());
|
||||
McpServerFeatures.SyncPromptSpecification specification = new McpServerFeatures.SyncPromptSpecification(prompt,
|
||||
|
|
@ -393,9 +375,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemovePromptWithoutCapability() {
|
||||
var serverWithoutPrompts = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.build();
|
||||
var serverWithoutPrompts = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThatThrownBy(() -> serverWithoutPrompts.removePrompt(TEST_PROMPT_NAME)).isInstanceOf(McpError.class)
|
||||
.hasMessage("Server must be configured with prompt capabilities");
|
||||
|
|
@ -408,8 +388,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
(exchange, req) -> new GetPromptResult("Test prompt description", List
|
||||
.of(new PromptMessage(McpSchema.Role.ASSISTANT, new McpSchema.TextContent("Test content")))));
|
||||
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.prompts(specification)
|
||||
.build();
|
||||
|
|
@ -421,8 +400,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
|
||||
@Test
|
||||
void testRemoveNonexistentPrompt() {
|
||||
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var mcpSyncServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.capabilities(ServerCapabilities.builder().prompts(true).build())
|
||||
.build();
|
||||
|
||||
|
|
@ -442,8 +420,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
var rootsReceived = new McpSchema.Root[1];
|
||||
var consumerCalled = new boolean[1];
|
||||
|
||||
var singleConsumerServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var singleConsumerServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeHandlers(List.of((exchange, roots) -> {
|
||||
consumerCalled[0] = true;
|
||||
if (!roots.isEmpty()) {
|
||||
|
|
@ -461,8 +438,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
var consumer2Called = new boolean[1];
|
||||
var rootsContent = new List[1];
|
||||
|
||||
var multipleConsumersServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var multipleConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeHandlers(List.of((exchange, roots) -> {
|
||||
consumer1Called[0] = true;
|
||||
rootsContent[0] = roots;
|
||||
|
|
@ -474,8 +450,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
onClose();
|
||||
|
||||
// Test error handling
|
||||
var errorHandlingServer = McpServer.sync(createMcpTransportProvider())
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
var errorHandlingServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||
.rootsChangeHandlers(List.of((exchange, roots) -> {
|
||||
throw new RuntimeException("Test error");
|
||||
}))
|
||||
|
|
@ -486,7 +461,7 @@ public abstract class AbstractMcpSyncServerTests {
|
|||
onClose();
|
||||
|
||||
// Test without consumers
|
||||
var noConsumersServer = McpServer.sync(createMcpTransportProvider()).serverInfo("test-server", "1.0.0").build();
|
||||
var noConsumersServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0").build();
|
||||
|
||||
assertThat(noConsumersServer).isNotNull();
|
||||
assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
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 -> Mono.just(new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), null,
|
||||
new McpSchema.JSONRPCResponse.JSONRPCError(McpSchema.ErrorCodes.INTERNAL_ERROR, t.getMessage(),
|
||||
null))));
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Default implementation for {@link McpTransportContext} which uses a Thread-safe map.
|
||||
* Objects of this kind are mutable.
|
||||
*
|
||||
* @author Dariusz Jędrzejczyk
|
||||
*/
|
||||
public class DefaultMcpTransportContext implements McpTransportContext {
|
||||
|
||||
private final Map<String, Object> storage;
|
||||
|
||||
/**
|
||||
* Create an empty instance.
|
||||
*/
|
||||
public DefaultMcpTransportContext() {
|
||||
this.storage = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
DefaultMcpTransportContext(Map<String, Object> storage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get(String key) {
|
||||
return this.storage.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String key, Object value) {
|
||||
this.storage.put(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows copying the contents.
|
||||
* @return new instance with the copy of the underlying map
|
||||
*/
|
||||
public McpTransportContext copy() {
|
||||
return new DefaultMcpTransportContext(new ConcurrentHashMap<>(this.storage));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ 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;
|
||||
|
||||
|
|
@ -86,7 +89,7 @@ public class McpAsyncServer {
|
|||
|
||||
private static final Logger logger = LoggerFactory.getLogger(McpAsyncServer.class);
|
||||
|
||||
private final McpServerTransportProvider mcpTransportProvider;
|
||||
private final McpServerTransportProviderBase mcpTransportProvider;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
|
|
@ -139,7 +142,56 @@ public class McpAsyncServer {
|
|||
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
|
||||
this.jsonSchemaValidator = jsonSchemaValidator;
|
||||
|
||||
Map<String, McpServerSession.RequestHandler<?>> requestHandlers = new HashMap<>();
|
||||
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
|
||||
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
|
||||
|
||||
mcpTransportProvider.setSessionFactory(transport -> new McpServerSession(UUID.randomUUID().toString(),
|
||||
requestTimeout, transport, this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers));
|
||||
}
|
||||
|
||||
McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
|
||||
McpServerFeatures.Async features, Duration requestTimeout,
|
||||
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator) {
|
||||
this.mcpTransportProvider = mcpTransportProvider;
|
||||
this.objectMapper = objectMapper;
|
||||
this.serverInfo = features.serverInfo();
|
||||
this.serverCapabilities = features.serverCapabilities();
|
||||
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);
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -174,25 +226,7 @@ public class McpAsyncServer {
|
|||
if (this.serverCapabilities.completions() != null) {
|
||||
requestHandlers.put(McpSchema.METHOD_COMPLETION_COMPLETE, completionCompleteRequestHandler());
|
||||
}
|
||||
|
||||
Map<String, McpServerSession.NotificationHandler> notificationHandlers = new HashMap<>();
|
||||
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_INITIALIZED, (exchange, params) -> Mono.empty());
|
||||
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers = features
|
||||
.rootsChangeConsumers();
|
||||
|
||||
if (Utils.isEmpty(rootsChangeConsumers)) {
|
||||
rootsChangeConsumers = List.of((exchange, roots) -> Mono.fromRunnable(() -> logger
|
||||
.warn("Roots list changed notification, but no consumers provided. Roots list changed: {}", roots)));
|
||||
}
|
||||
|
||||
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED,
|
||||
asyncRootsListChangedNotificationHandler(rootsChangeConsumers));
|
||||
|
||||
mcpTransportProvider.setSessionFactory(
|
||||
transport -> new McpServerSession(UUID.randomUUID().toString(), requestTimeout, transport,
|
||||
this::asyncInitializeRequestHandler, Mono::empty, requestHandlers, notificationHandlers));
|
||||
return requestHandlers;
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
|
|
@ -258,7 +292,7 @@ public class McpAsyncServer {
|
|||
this.mcpTransportProvider.close();
|
||||
}
|
||||
|
||||
private McpServerSession.NotificationHandler asyncRootsListChangedNotificationHandler(
|
||||
private McpNotificationHandler asyncRootsListChangedNotificationHandler(
|
||||
List<BiFunction<McpAsyncServerExchange, List<McpSchema.Root>, Mono<Void>>> rootsChangeConsumers) {
|
||||
return (exchange, params) -> exchange.listRoots()
|
||||
.flatMap(listRootsResult -> Flux.fromIterable(rootsChangeConsumers)
|
||||
|
|
@ -450,7 +484,7 @@ public class McpAsyncServer {
|
|||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
List<Tool> tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList();
|
||||
|
||||
|
|
@ -458,7 +492,7 @@ public class McpAsyncServer {
|
|||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<CallToolResult> toolsCallRequestHandler() {
|
||||
private McpRequestHandler<CallToolResult> toolsCallRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.CallToolRequest callToolRequest = objectMapper.convertValue(params,
|
||||
new TypeReference<McpSchema.CallToolRequest>() {
|
||||
|
|
@ -551,7 +585,7 @@ public class McpAsyncServer {
|
|||
resourcesUpdatedNotification);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
var resourceList = this.resources.values()
|
||||
.stream()
|
||||
|
|
@ -561,7 +595,7 @@ public class McpAsyncServer {
|
|||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
|
||||
return (exchange, params) -> Mono
|
||||
.just(new McpSchema.ListResourceTemplatesResult(this.getResourceTemplates(), null));
|
||||
|
||||
|
|
@ -585,7 +619,7 @@ public class McpAsyncServer {
|
|||
return list;
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
|
||||
new TypeReference<McpSchema.ReadResourceRequest>() {
|
||||
|
|
@ -678,7 +712,7 @@ public class McpAsyncServer {
|
|||
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
// TODO: Implement pagination
|
||||
// McpSchema.PaginatedRequest request = objectMapper.convertValue(params,
|
||||
|
|
@ -694,7 +728,7 @@ public class McpAsyncServer {
|
|||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.GetPromptResult> promptsGetRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.GetPromptRequest promptRequest = objectMapper.convertValue(params,
|
||||
new TypeReference<McpSchema.GetPromptRequest>() {
|
||||
|
|
@ -740,7 +774,7 @@ public class McpAsyncServer {
|
|||
loggingMessageNotification);
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<Object> setLoggerRequestHandler() {
|
||||
private McpRequestHandler<Object> setLoggerRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
return Mono.defer(() -> {
|
||||
|
||||
|
|
@ -759,7 +793,7 @@ public class McpAsyncServer {
|
|||
};
|
||||
}
|
||||
|
||||
private McpServerSession.RequestHandler<McpSchema.CompleteResult> completionCompleteRequestHandler() {
|
||||
private McpRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHandler() {
|
||||
return (exchange, params) -> {
|
||||
McpSchema.CompleteRequest request = parseCompletionParams(params);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import java.util.Collections;
|
|||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
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.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.McpSession;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
|
|
@ -25,13 +26,15 @@ import reactor.core.publisher.Mono;
|
|||
*/
|
||||
public class McpAsyncServerExchange {
|
||||
|
||||
private final McpServerSession session;
|
||||
private final String sessionId;
|
||||
|
||||
private final McpLoggableSession session;
|
||||
|
||||
private final McpSchema.ClientCapabilities clientCapabilities;
|
||||
|
||||
private final McpSchema.Implementation clientInfo;
|
||||
|
||||
private volatile LoggingLevel minLoggingLevel = LoggingLevel.INFO;
|
||||
private final McpTransportContext transportContext;
|
||||
|
||||
private static final TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
|
||||
};
|
||||
|
|
@ -51,12 +54,39 @@ public class McpAsyncServerExchange {
|
|||
* @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)}
|
||||
*/
|
||||
public McpAsyncServerExchange(McpServerSession session, McpSchema.ClientCapabilities clientCapabilities,
|
||||
@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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -75,6 +105,24 @@ public class McpAsyncServerExchange {
|
|||
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
|
||||
|
|
@ -170,7 +218,7 @@ public class McpAsyncServerExchange {
|
|||
}
|
||||
|
||||
return Mono.defer(() -> {
|
||||
if (this.isNotificationForLevelAllowed(loggingMessageNotification.level())) {
|
||||
if (this.session.isNotificationForLevelAllowed(loggingMessageNotification.level())) {
|
||||
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingMessageNotification);
|
||||
}
|
||||
return Mono.empty();
|
||||
|
|
@ -205,11 +253,7 @@ public class McpAsyncServerExchange {
|
|||
*/
|
||||
void setMinLoggingLevel(LoggingLevel minLoggingLevel) {
|
||||
Assert.notNull(minLoggingLevel, "minLoggingLevel must not be null");
|
||||
this.minLoggingLevel = minLoggingLevel;
|
||||
}
|
||||
|
||||
private boolean isNotificationForLevelAllowed(LoggingLevel loggingLevel) {
|
||||
return loggingLevel.level() >= this.minLoggingLevel.level();
|
||||
this.session.setMinLoggingLevel(minLoggingLevel);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
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);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* A handler for client-initiated notifications.
|
||||
*/
|
||||
public interface McpNotificationHandler {
|
||||
|
||||
/**
|
||||
* Handles a notification from the client.
|
||||
* @param exchange the exchange associated with the client that allows calling back to
|
||||
* the connected client or inspecting its capabilities.
|
||||
* @param params the parameters of the notification.
|
||||
* @return a Mono that completes once the notification is handled.
|
||||
*/
|
||||
Mono<Void> handle(McpAsyncServerExchange exchange, Object params);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* A handler for client-initiated requests.
|
||||
*
|
||||
* @param <T> the type of the response that is expected as a result of handling the
|
||||
* request.
|
||||
*/
|
||||
public interface McpRequestHandler<T> {
|
||||
|
||||
/**
|
||||
* Handles a request from the client.
|
||||
* @param exchange the exchange associated with the client that allows calling back to
|
||||
* the connected client or inspecting its capabilities.
|
||||
* @param params the parameters of the request.
|
||||
* @return a Mono that will emit the response to the request.
|
||||
*/
|
||||
Mono<T> handle(McpAsyncServerExchange exchange, Object params);
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue