From 821b4c8676172fa46576160d3cc5925419fd152b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Sun, 28 Sep 2025 15:23:22 +0800 Subject: [PATCH 01/37] =?UTF-8?q?=E6=B7=BB=E5=8A=A0sdk=E5=86=99=E6=B3=95?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/java/plugins/tool-mcp-server/pom.xml | 5 + ...tMcpStreamableServerTransportProvider.java | 473 ++++++++++++++++++ .../fel/tool/mcp/server/SdkMcpServer.java | 30 ++ 3 files changed, 508 insertions(+) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java diff --git a/framework/fel/java/plugins/tool-mcp-server/pom.xml b/framework/fel/java/plugins/tool-mcp-server/pom.xml index 514874710..42c088576 100644 --- a/framework/fel/java/plugins/tool-mcp-server/pom.xml +++ b/framework/fel/java/plugins/tool-mcp-server/pom.xml @@ -41,6 +41,11 @@ org.fitframework.fel tool-mcp-common + + io.modelcontextprotocol.sdk + mcp + 0.13.0 + diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java new file mode 100644 index 000000000..7fb9f636a --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java @@ -0,0 +1,473 @@ +package modelengine.fel.tool.mcp.server; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.*; +import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.KeepAliveScheduler; +import modelengine.fit.http.server.HttpClassicServerRequest; +import modelengine.fit.http.server.HttpClassicServerResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +public class DefaultMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { + + private static final Logger logger = LoggerFactory.getLogger(DefaultMcpStreamableServerTransportProvider.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 sessions = new ConcurrentHashMap<>(); + + private McpTransportContextExtractor contextExtractor; + + private volatile boolean isClosing = false; + + private KeepAliveScheduler keepAliveScheduler; + + private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, String mcpEndpoint, + McpTransportContextExtractor contextExtractor, boolean disallowDelete, + Duration keepAliveInterval) { + 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(); + + if (keepAliveInterval != null) { + this.keepAliveScheduler = KeepAliveScheduler + .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(this.sessions.values())) + .initialDelay(keepAliveInterval) + .interval(keepAliveInterval) + .build(); + + this.keepAliveScheduler.start(); + } + } + + @Override + public List protocolVersions() { + return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26); + } + + @Override + public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono 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 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(); + }).then().doOnSuccess(v -> { + sessions.clear(); + if (this.keepAliveScheduler != null) { + this.keepAliveScheduler.shutdown(); + } + }); + } + + /** + * Returns the WebFlux router function that defines the transport's HTTP endpoints. + * This router function should be integrated into the application's web configuration. + * + *

+ * The router function defines one endpoint with three methods: + *

    + *
  • GET {messageEndpoint} - For the client listening SSE stream
  • + *
  • POST {messageEndpoint} - For receiving client messages
  • + *
  • DELETE {messageEndpoint} - For removing sessions
  • + *
+ * @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 handleGet(HttpClassicServerRequest request) { + if (isClosing) { + return HttpClassicServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + return Mono.defer(() -> { + List acceptHeaders = request.headers().asHttpHeaders().getAccept(); + if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { + return HttpClassicServerResponse.badRequest().build(); + } + + if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + return HttpClassicServerResponse.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 HttpClassicServerResponse.notFound().build(); + } + + if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) { + String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); + return HttpClassicServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(session.replay(lastId) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), + ServerSentEvent.class); + } + + return HttpClassicServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(Flux.>create(sink -> { + WebFluxStreamableMcpSessionTransport sessionTransport = new WebFluxStreamableMcpSessionTransport( + sink); + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session + .listeningStream(sessionTransport); + sink.onDispose(listeningStream::close); + // TODO Clarify why the outer context is not present in the + // Flux.create sink? + }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), 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 handlePost(HttpClassicServerRequest request) { + if (isClosing) { + return HttpClassicServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + List acceptHeaders = request.headers().asHttpHeaders().getAccept(); + if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON) + && acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) { + return HttpClassicServerResponse.badRequest().build(); + } + + return request.bodyToMono(String.class).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() { + }); + 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 -> HttpClassicServerResponse.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 HttpClassicServerResponse.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 HttpClassicServerResponse.status(HttpStatus.NOT_FOUND) + .bodyValue(new McpError("Session not found: " + sessionId)); + } + + if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { + return session.accept(jsonrpcResponse).then(HttpClassicServerResponse.accepted().build()); + } + else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + return session.accept(jsonrpcNotification).then(HttpClassicServerResponse.accepted().build()); + } + else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + return HttpClassicServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(Flux.>create(sink -> { + WebFluxStreamableMcpSessionTransport st = new WebFluxStreamableMcpSessionTransport(sink); + Mono stream = session.responseStream(jsonrpcRequest, st); + Disposable streamSubscription = stream.onErrorComplete(err -> { + sink.error(err); + return true; + }).contextWrite(sink.contextView()).subscribe(); + sink.onCancel(streamSubscription); + // TODO Clarify why the outer context is not present in the + // Flux.create sink? + }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), + ServerSentEvent.class); + } + else { + return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Unknown message type")); + } + } + catch (IllegalArgumentException | IOException e) { + logger.error("Failed to deserialize message: {}", e.getMessage()); + return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Invalid message format")); + } + }) + .switchIfEmpty(HttpClassicServerResponse.badRequest().build()) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + } + + private Mono handleDelete(HttpClassicServerRequest request) { + if (isClosing) { + return HttpClassicServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + return Mono.defer(() -> { + if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + return HttpClassicServerResponse.badRequest().build(); // TODO: say we need a session + // id + } + + if (this.disallowDelete) { + return HttpClassicServerResponse.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 HttpClassicServerResponse.notFound().build(); + } + + return session.delete().then(HttpClassicServerResponse.ok().build()); + }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + } + + private class DefaultStreamableMcpSessionTransport implements McpStreamableServerTransport { + + private final FluxSink> sink; + + public DefaultStreamableMcpSessionTransport(FluxSink> sink) { + this.sink = sink; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return this.sendMessage(message, null); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.fromSupplier(() -> { + try { + return objectMapper.writeValueAsString(message); + } + catch (IOException e) { + throw Exceptions.propagate(e); + } + }).doOnNext(jsonText -> { + ServerSentEvent 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 unmarshalFrom(Object data, TypeReference typeRef) { + return objectMapper.convertValue(data, typeRef); + } + + @Override + public Mono 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 DefaultMcpStreamableServerTransportProvider}. + *

+ * This builder provides a fluent API for configuring and creating instances of + * DefaultMcpStreamableServerTransportProvider with custom settings. + */ + public static class Builder { + + private ObjectMapper objectMapper; + + private String mcpEndpoint = "/mcp"; + + private McpTransportContextExtractor contextExtractor = ( + serverRequest) -> McpTransportContext.EMPTY; + + private boolean disallowDelete; + + private Duration keepAliveInterval; + + 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 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; + } + + /** + * Sets the keep-alive interval for the server transport. + * @param keepAliveInterval The interval for sending keep-alive messages. If null, + * no keep-alive will be scheduled. + * @return this builder instance + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + return this; + } + + /** + * Builds a new instance of {@link DefaultMcpStreamableServerTransportProvider} with + * the configured settings. + * @return A new DefaultMcpStreamableServerTransportProvider instance + * @throws IllegalStateException if required parameters are not set + */ + public DefaultMcpStreamableServerTransportProvider build() { + Assert.notNull(objectMapper, "ObjectMapper must be set"); + Assert.notNull(mcpEndpoint, "Message endpoint must be set"); + + return new DefaultMcpStreamableServerTransportProvider(objectMapper, mcpEndpoint, contextExtractor, + disallowDelete, keepAliveInterval); + } + + } + +} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java new file mode 100644 index 000000000..1417ff81c --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java @@ -0,0 +1,30 @@ +package modelengine.fel.tool.mcp.server; + + +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.service.ToolExecuteService; +import modelengine.fitframework.annotation.Component; +import io.modelcontextprotocol.server.McpSyncServer; + +import java.time.Duration; + +@Component +public class SdkMcpServer { + private final McpSyncServer mcpSyncServer; + + public SdkMcpServer(DefaultMcpStreamableServerTransportProvider transportProvider) { + this.mcpSyncServer = McpServer.sync(transportProvider) + .serverInfo("hkx-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, true) // Enable resource support + .tools(true) // Enable tool support + .prompts(true) // Enable prompt support + .logging() // Enable logging support + .completions() // Enable completions support + .build()) + .requestTimeout(Duration.ofSeconds(10)) + .build(); + } + +} From e4f4006738971fcd4d0a30a44de53a9661eb28db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Mon, 29 Sep 2025 17:12:43 +0800 Subject: [PATCH 02/37] =?UTF-8?q?=E6=8E=A5=E5=85=A5Fit=20Http=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/java/plugins/tool-mcp-server/pom.xml | 2 +- ...tMcpStreamableServerTransportProvider.java | 661 +++++++++++------- ...xMcpStreamableServerTransportProvider.java | 447 ++++++++++++ .../fel/tool/mcp/server/SdkMcpServer.java | 3 +- .../fel/tool/mcp/server/ServerSentEvent.java | 264 +++++++ .../mcp/server/support/SseServerResponse.java | 261 +++++++ 6 files changed, 1390 insertions(+), 248 deletions(-) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FluxMcpStreamableServerTransportProvider.java create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java diff --git a/framework/fel/java/plugins/tool-mcp-server/pom.xml b/framework/fel/java/plugins/tool-mcp-server/pom.xml index 42c088576..306ff8c3b 100644 --- a/framework/fel/java/plugins/tool-mcp-server/pom.xml +++ b/framework/fel/java/plugins/tool-mcp-server/pom.xml @@ -44,7 +44,7 @@ io.modelcontextprotocol.sdk mcp - 0.13.0 + 0.12.0 diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java index 7fb9f636a..f5271ec09 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java @@ -7,61 +7,85 @@ import io.modelcontextprotocol.spec.*; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; +import modelengine.fit.http.annotation.*; +import modelengine.fit.http.entity.Entity; +import modelengine.fit.http.protocol.HttpResponseStatus; +import modelengine.fit.http.protocol.MessageHeaderNames; +import modelengine.fit.http.protocol.MimeType; import modelengine.fit.http.server.HttpClassicServerRequest; import modelengine.fit.http.server.HttpClassicServerResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -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.time.Duration; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +@RequestMapping("/mcp/streamable") public class DefaultMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { private static final Logger logger = LoggerFactory.getLogger(DefaultMcpStreamableServerTransportProvider.class); + /** + * Event type for JSON-RPC messages sent through the SSE connection. + */ public static final String MESSAGE_EVENT_TYPE = "message"; - private final ObjectMapper objectMapper; + /** + * Event type for sending the message endpoint URI to clients. + */ + public static final String ENDPOINT_EVENT_TYPE = "endpoint"; - private final String mcpEndpoint; + /** + * Default base URL for the message endpoint. + */ + public static final String DEFAULT_BASE_URL = ""; + /** + * Flag indicating whether DELETE requests are disallowed on the endpoint. + */ private final boolean disallowDelete; - private final RouterFunction routerFunction; + private final ObjectMapper objectMapper; private McpStreamableServerSession.Factory sessionFactory; + /** + * Map of active client sessions, keyed by mcp-session-id. + */ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); private McpTransportContextExtractor contextExtractor; + /** + * Flag indicating if the transport is shutting down. + */ private volatile boolean isClosing = false; private KeepAliveScheduler keepAliveScheduler; - private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, String mcpEndpoint, - McpTransportContextExtractor contextExtractor, boolean disallowDelete, - Duration keepAliveInterval) { + /** + * Constructs a new DefaultMcpStreamableServerTransportProvider instance. + * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + * of messages. + * @param disallowDelete Whether to disallow DELETE requests on the endpoint. + * @throws IllegalArgumentException if any parameter is null + */ + private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, + boolean disallowDelete, McpTransportContextExtractor contextExtractor, + Duration keepAliveInterval) { 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"); + Assert.notNull(contextExtractor, "McpTransportContextExtractor 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(); + this.contextExtractor = contextExtractor; if (keepAliveInterval != null) { this.keepAliveScheduler = KeepAliveScheduler @@ -84,282 +108,451 @@ public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) this.sessionFactory = sessionFactory; } + /** + * Broadcasts a notification to all connected clients through their SSE connections. + * If any errors occur during sending to a particular client, they are logged but + * don't prevent sending to other clients. + * @param method The method name for the notification + * @param params The parameters for the notification + * @return A Mono that completes when the broadcast attempt is finished + */ @Override public Mono notifyClients(String method, Object params) { - if (sessions.isEmpty()) { + if (this.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()); + logger.debug("Attempting to broadcast message to {} active sessions", this.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(); + return Mono.fromRunnable(() -> { + this.sessions.values().parallelStream().forEach(session -> { + try { + session.sendNotification(method, params).block(); + } + catch (Exception e) { + logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); + } + }); + }); } + /** + * Initiates a graceful shutdown of the transport. + * @return A Mono that completes when all cleanup operations are finished + */ @Override public Mono closeGracefully() { - return Mono.defer(() -> { + return Mono.fromRunnable(() -> { this.isClosing = true; - return Flux.fromIterable(sessions.values()) - .doFirst(() -> logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size())) - .flatMap(McpStreamableServerSession::closeGracefully) - .then(); + logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size()); + + this.sessions.values().parallelStream().forEach(session -> { + try { + session.closeGracefully().block(); + } + catch (Exception e) { + logger.error("Failed to close session {}: {}", session.getId(), e.getMessage()); + } + }); + + this.sessions.clear(); + logger.debug("Graceful shutdown completed"); }).then().doOnSuccess(v -> { - sessions.clear(); if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); } }); } - /** - * Returns the WebFlux router function that defines the transport's HTTP endpoints. - * This router function should be integrated into the application's web configuration. - * - *

- * The router function defines one endpoint with three methods: - *

    - *
  • GET {messageEndpoint} - For the client listening SSE stream
  • - *
  • POST {messageEndpoint} - For receiving client messages
  • - *
  • DELETE {messageEndpoint} - For removing sessions
  • - *
- * @return The configured {@link RouterFunction} for handling HTTP requests - */ - public RouterFunction getRouterFunction() { - return this.routerFunction; - } /** - * Opens the listening SSE streams for clients. + * Setup the listening SSE connections and message replay. * @param request The incoming server request - * @return A Mono which emits a response with the SSE event stream */ - private Mono handleGet(HttpClassicServerRequest request) { - if (isClosing) { - return HttpClassicServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + @GetMapping + private void handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (this.isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + response.entity(Entity.createText(response, "Server is shutting down")); + return; + } + + List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); + if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value())) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + response.entity(Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM")); + return; } McpTransportContext transportContext = this.contextExtractor.extract(request); - return Mono.defer(() -> { - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { - return HttpClassicServerResponse.badRequest().build(); - } + if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + response.entity(Entity.createText(response, "Session ID required in mcp-session-id header")); + return; + } - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { - return HttpClassicServerResponse.badRequest().build(); // TODO: say we need a session - // id - } + String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); + McpStreamableServerSession session = this.sessions.get(sessionId); - String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); + if (session == null) { + response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); + return; + } - McpStreamableServerSession session = this.sessions.get(sessionId); + logger.debug("Handling GET request for session: {}", sessionId); - if (session == null) { - return HttpClassicServerResponse.notFound().build(); - } + try { - if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) { - String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); - return HttpClassicServerResponse.ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(session.replay(lastId) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), - ServerSentEvent.class); - } + return HttpClassicServerResponse.sse(sseBuilder -> { + sseBuilder.onTimeout(() -> { + logger.debug("SSE connection timed out for session: {}", sessionId); + }); + + DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( + sessionId, sseBuilder); + + // Check if this is a replay request + if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { + String lastId = request.headers().first(HttpHeaders.LAST_EVENT_ID).orElse(""); - return HttpClassicServerResponse.ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(Flux.>create(sink -> { - WebFluxStreamableMcpSessionTransport sessionTransport = new WebFluxStreamableMcpSessionTransport( - sink); - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session - .listeningStream(sessionTransport); - sink.onDispose(listeningStream::close); - // TODO Clarify why the outer context is not present in the - // Flux.create sink? - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), ServerSentEvent.class); - - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + try { + session.replay(lastId) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .toIterable() + .forEach(message -> { + try { + sessionTransport.sendMessage(message) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + } + catch (Exception e) { + logger.error("Failed to replay message: {}", e.getMessage()); + sseBuilder.error(e); + } + }); + } + catch (Exception e) { + logger.error("Failed to replay messages: {}", e.getMessage()); + sseBuilder.error(e); + } + } + else { + // Establish new listening stream + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session + .listeningStream(sessionTransport); + + sseBuilder.onComplete(() -> { + logger.debug("SSE connection completed for session: {}", sessionId); + listeningStream.close(); + }); + } + }, Duration.ZERO); + } + catch (Exception e) { + logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + } } /** - * Handles incoming JSON-RPC messages from clients. + * Handles POST requests for 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 handlePost(HttpClassicServerRequest request) { - if (isClosing) { - return HttpClassicServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + @PostMapping + private void handlePost(@RequestBody String body,HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (this.isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + response.entity(Entity.createText(response, "Server is shutting down")); + return; + } + + List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); + if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value()) + || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + response.entity(Entity.createObject(response, new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON"))); + return; } McpTransportContext transportContext = this.contextExtractor.extract(request); - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON) - && acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) { - return HttpClassicServerResponse.badRequest().build(); - } + try { + + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body); + + // Handle initialization request + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest + && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { + McpSchema.InitializeRequest initializeRequest = objectMapper.convertValue(jsonrpcRequest.params(), + new TypeReference() { + }); + McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory + .startSession(initializeRequest); + this.sessions.put(init.session().getId(), init.session()); + + try { + McpSchema.InitializeResult initResult = init.initResult().block(); + response.statusCode(HttpResponseStatus.OK.statusCode()); + response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); + response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); + response.entity(Entity.createObject(response, + new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null))); + return; + } + catch (Exception e) { + logger.error("Failed to initialize session: {}", e.getMessage()); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + response.entity(Entity.createObject(response, new McpError(e.getMessage()))); + return; + } + } + + // Handle other messages that require a session + if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + response.entity(Entity.createObject(response, new McpError("Session ID missing"))); + return; + } + + String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); + McpStreamableServerSession session = this.sessions.get(sessionId); + + if (session == null) { + response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); + response.entity(Entity.createObject(response, new McpError("Session not found: " + sessionId))); + return; + } + + if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { + session.accept(jsonrpcResponse) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); + } + else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + session.accept(jsonrpcNotification) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); + } + else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + // For streaming responses, we need to return SSE + return HttpClassicServerResponse.sse(sseBuilder -> { + sseBuilder.onComplete(() -> { + logger.debug("Request response stream completed for session: {}", sessionId); + }); + sseBuilder.onTimeout(() -> { + logger.debug("Request response stream timed out for session: {}", sessionId); + }); + + DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( + sessionId, sseBuilder); - return request.bodyToMono(String.class).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() { - }); - 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 -> HttpClassicServerResponse.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 HttpClassicServerResponse.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 HttpClassicServerResponse.status(HttpStatus.NOT_FOUND) - .bodyValue(new McpError("Session not found: " + sessionId)); - } - - if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - return session.accept(jsonrpcResponse).then(HttpClassicServerResponse.accepted().build()); - } - else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - return session.accept(jsonrpcNotification).then(HttpClassicServerResponse.accepted().build()); - } - else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - return HttpClassicServerResponse.ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(Flux.>create(sink -> { - WebFluxStreamableMcpSessionTransport st = new WebFluxStreamableMcpSessionTransport(sink); - Mono stream = session.responseStream(jsonrpcRequest, st); - Disposable streamSubscription = stream.onErrorComplete(err -> { - sink.error(err); - return true; - }).contextWrite(sink.contextView()).subscribe(); - sink.onCancel(streamSubscription); - // TODO Clarify why the outer context is not present in the - // Flux.create sink? - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), - ServerSentEvent.class); - } - else { - return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Unknown message type")); - } + session.responseStream(jsonrpcRequest, sessionTransport) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); } - catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); - return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Invalid message format")); + catch (Exception e) { + logger.error("Failed to handle request stream: {}", e.getMessage()); + sseBuilder.error(e); } - }) - .switchIfEmpty(HttpClassicServerResponse.badRequest().build()) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + }, Duration.ZERO); + } + else { + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + response.entity(Entity.createObject(response, new McpError("Unknown message type"))); + } + } + catch (IllegalArgumentException | IOException e) { + logger.error("Failed to deserialize message: {}", e.getMessage()); + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + response.entity(Entity.createObject(response, new McpError("Invalid message format"))); + } + catch (Exception e) { + logger.error("Error handling message: {}", e.getMessage()); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + response.entity(Entity.createObject(response, new McpError(e.getMessage()))); + } } - private Mono handleDelete(HttpClassicServerRequest request) { - if (isClosing) { - return HttpClassicServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).bodyValue("Server is shutting down"); + /** + * Handles DELETE requests for session deletion. + * @param request The incoming server request + */ + @DeleteMapping + private void handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (this.isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + response.entity(Entity.createText(response, "Server is shutting down")); + return; + } + + if (this.disallowDelete) { + response.statusCode(HttpResponseStatus.METHOD_NOT_ALLOWED.statusCode()); + return; } McpTransportContext transportContext = this.contextExtractor.extract(request); - return Mono.defer(() -> { - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { - return HttpClassicServerResponse.badRequest().build(); // TODO: say we need a session - // id - } - - if (this.disallowDelete) { - return HttpClassicServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).build(); - } - - String sessionId = request.headers().asHttpHeaders().getFirst(HttpHeaders.MCP_SESSION_ID); + if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + response.entity(Entity.createText(response, "Session ID required in mcp-session-id header")); + return; + } - McpStreamableServerSession session = this.sessions.get(sessionId); + String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); + McpStreamableServerSession session = this.sessions.get(sessionId); - if (session == null) { - return HttpClassicServerResponse.notFound().build(); - } + if (session == null) { + response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); + return; + } - return session.delete().then(HttpClassicServerResponse.ok().build()); - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + try { + session.delete().contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); + this.sessions.remove(sessionId); + response.statusCode(HttpResponseStatus.OK.statusCode()); + } + catch (Exception e) { + logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + response.entity(Entity.createObject(response, new McpError(e.getMessage()))); + } } + /** + * Implementation of McpStreamableServerTransport for WebMVC SSE sessions. This class + * handles the transport-level communication for a specific client session. + * + *

+ * This class is thread-safe and uses a ReentrantLock to synchronize access to the + * underlying SSE builder to prevent race conditions when multiple threads attempt to + * send messages concurrently. + */ private class DefaultStreamableMcpSessionTransport implements McpStreamableServerTransport { - private final FluxSink> sink; + private final String sessionId; + + private final SseBuilder sseBuilder; + + private final ReentrantLock lock = new ReentrantLock(); - public DefaultStreamableMcpSessionTransport(FluxSink> sink) { - this.sink = sink; + private volatile boolean closed = false; + + /** + * Creates a new session transport with the specified ID and SSE builder. + * @param sessionId The unique identifier for this session + * @param sseBuilder The SSE builder for sending server events to the client + */ + DefaultStreamableMcpSessionTransport(String sessionId, SseBuilder sseBuilder) { + this.sessionId = sessionId; + this.sseBuilder = sseBuilder; + logger.debug("Streamable session transport {} initialized with SSE builder", sessionId); } + /** + * Sends a JSON-RPC message to the client through the SSE connection. + * @param message The JSON-RPC message to send + * @return A Mono that completes when the message has been sent + */ @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return this.sendMessage(message, null); + return sendMessage(message, null); } + /** + * Sends a JSON-RPC message to the client through the SSE connection with a + * specific message ID. + * @param message The JSON-RPC message to send + * @param messageId The message ID for SSE event identification + * @return A Mono that completes when the message has been sent + */ @Override public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { - return Mono.fromSupplier(() -> { + return Mono.fromRunnable(() -> { + if (this.closed) { + logger.debug("Attempted to send message to closed session: {}", this.sessionId); + return; + } + + this.lock.lock(); try { - return objectMapper.writeValueAsString(message); + if (this.closed) { + logger.debug("Session {} was closed during message send attempt", this.sessionId); + return; + } + + String jsonText = objectMapper.writeValueAsString(message); + this.sseBuilder.id(messageId != null ? messageId : this.sessionId) + .event(MESSAGE_EVENT_TYPE) + .data(jsonText); + logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId); + } + catch (Exception e) { + logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); + try { + this.sseBuilder.error(e); + } + catch (Exception errorException) { + logger.error("Failed to send error to SSE builder for session {}: {}", this.sessionId, + errorException.getMessage()); + } } - catch (IOException e) { - throw Exceptions.propagate(e); + finally { + this.lock.unlock(); } - }).doOnNext(jsonText -> { - ServerSentEvent 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(); + }); } + /** + * Converts data from one type to another using the configured ObjectMapper. + * @param data The source data object to convert + * @param typeRef The target type reference + * @return The converted object of type T + * @param The target type + */ @Override public T unmarshalFrom(Object data, TypeReference typeRef) { return objectMapper.convertValue(data, typeRef); } + /** + * Initiates a graceful shutdown of the transport. + * @return A Mono that completes when the shutdown is complete + */ @Override public Mono closeGracefully() { - return Mono.fromRunnable(sink::complete); + return Mono.fromRunnable(() -> { + DefaultStreamableMcpSessionTransport.this.close(); + }); } + /** + * Closes the transport immediately. + */ @Override public void close() { - sink.complete(); + this.lock.lock(); + try { + if (this.closed) { + logger.debug("Session transport {} already closed", this.sessionId); + return; + } + + this.closed = true; + + this.sseBuilder.complete(); + logger.debug("Successfully completed SSE builder for session {}", sessionId); + } + catch (Exception e) { + logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage()); + } + finally { + this.lock.unlock(); + } } } @@ -370,27 +563,18 @@ public static Builder builder() { /** * Builder for creating instances of {@link DefaultMcpStreamableServerTransportProvider}. - *

- * This builder provides a fluent API for configuring and creating instances of - * DefaultMcpStreamableServerTransportProvider with custom settings. */ public static class Builder { private ObjectMapper objectMapper; - private String mcpEndpoint = "/mcp"; + private boolean disallowDelete = false; private McpTransportContextExtractor contextExtractor = ( - serverRequest) -> McpTransportContext.EMPTY; - - private boolean disallowDelete; + HttpClassicServerRequest) -> McpTransportContext.EMPTY; private Duration keepAliveInterval; - private Builder() { - // used by a static method - } - /** * Sets the ObjectMapper to use for JSON serialization/deserialization of MCP * messages. @@ -405,14 +589,12 @@ public Builder objectMapper(ObjectMapper objectMapper) { } /** - * Sets the endpoint URI where clients should send their JSON-RPC messages. - * @param messageEndpoint The message endpoint URI. Must not be null. + * Sets whether to disallow DELETE requests on the endpoint. + * @param disallowDelete true to disallow DELETE requests, false otherwise * @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; + public Builder disallowDelete(boolean disallowDelete) { + this.disallowDelete = disallowDelete; return this; } @@ -433,20 +615,10 @@ public Builder contextExtractor(McpTransportContextExtractor sessions = new ConcurrentHashMap<>(); + + private McpTransportContextExtractor contextExtractor; + + private volatile boolean isClosing = false; + + private KeepAliveScheduler keepAliveScheduler; + + private FluxMcpStreamableServerTransportProvider(ObjectMapper objectMapper, + McpTransportContextExtractor contextExtractor, boolean disallowDelete, + Duration keepAliveInterval) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Assert.notNull(contextExtractor, "Context extractor must not be null"); + + this.objectMapper = objectMapper; + this.contextExtractor = contextExtractor; + this.disallowDelete = disallowDelete; + + if (keepAliveInterval != null) { + this.keepAliveScheduler = KeepAliveScheduler + .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(this.sessions.values())) + .initialDelay(keepAliveInterval) + .interval(keepAliveInterval) + .build(); + + this.keepAliveScheduler.start(); + } + } + + @Override + public List protocolVersions() { + return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26); + } + + @Override + public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + public Mono 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 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(); + }).then().doOnSuccess(v -> { + sessions.clear(); + if (this.keepAliveScheduler != null) { + this.keepAliveScheduler.shutdown(); + } + }); + } + + /** + * Opens the listening SSE streams for clients. + * @param request The incoming server request + */ + @GetMapping + private void handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + response.reasonPhrase("Server is shutting down"); + return; + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + return Mono.defer(() -> { + String acceptHeader = request.headers().first("Accept"); + List acceptHeaders = request.headers().asHttpHeaders().getAccept(); + if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { + return HttpClassicServerResponse.badRequest().build(); + } + + if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + return HttpClassicServerResponse.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 HttpClassicServerResponse.notFound().build(); + } + + if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) { + String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); + return HttpClassicServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(session.replay(lastId) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), + ServerSentEvent.class); + } + + return HttpClassicServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(Flux.>create(sink -> { + FluxStreamableMcpSessionTransport sessionTransport = new FluxStreamableMcpSessionTransport( + sink); + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session + .listeningStream(sessionTransport); + sink.onDispose(listeningStream::close); + // TODO Clarify why the outer context is not present in the + // Flux.create sink? + }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), 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. + */ + @PostMapping + private void handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + response.reasonPhrase("Server is shutting down"); + return; + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + List acceptHeaders = request.headers().asHttpHeaders().getAccept(); + if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON) + && acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) { + return HttpClassicServerResponse.badRequest().build(); + } + + return request.bodyToMono(String.class).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() { + }); + 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 -> HttpClassicServerResponse.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 HttpClassicServerResponse.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 HttpClassicServerResponse.status(HttpStatus.NOT_FOUND) + .bodyValue(new McpError("Session not found: " + sessionId)); + } + + if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { + return session.accept(jsonrpcResponse).then(HttpClassicServerResponse.accepted().build()); + } + else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + return session.accept(jsonrpcNotification).then(HttpClassicServerResponse.accepted().build()); + } + else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + return HttpClassicServerResponse.ok() + .contentType(MediaType.TEXT_EVENT_STREAM) + .body(Flux.>create(sink -> { + FluxStreamableMcpSessionTransport st = new FluxStreamableMcpSessionTransport(sink); + Mono stream = session.responseStream(jsonrpcRequest, st); + Disposable streamSubscription = stream.onErrorComplete(err -> { + sink.error(err); + return true; + }).contextWrite(sink.contextView()).subscribe(); + sink.onCancel(streamSubscription); + // TODO Clarify why the outer context is not present in the + // Flux.create sink? + }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), + ServerSentEvent.class); + } + else { + return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Unknown message type")); + } + } + catch (IllegalArgumentException | IOException e) { + logger.error("Failed to deserialize message: {}", e.getMessage()); + return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Invalid message format")); + } + }) + .switchIfEmpty(HttpClassicServerResponse.badRequest().build()) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + } + + @DeleteMapping + private void handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + response.reasonPhrase("Server is shutting down"); + return; + } + + McpTransportContext transportContext = this.contextExtractor.extract(request); + + return Mono.defer(() -> { + if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { + return HttpClassicServerResponse.badRequest().build(); // TODO: say we need a session + // id + } + + if (this.disallowDelete) { + return HttpClassicServerResponse.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 HttpClassicServerResponse.notFound().build(); + } + + return session.delete().then(HttpClassicServerResponse.ok().build()); + }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); + } + + private class FluxStreamableMcpSessionTransport implements McpStreamableServerTransport { + + private final FluxSink> sink; + + public FluxStreamableMcpSessionTransport(FluxSink> sink) { + this.sink = sink; + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return this.sendMessage(message, null); + } + + + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.fromSupplier(() -> { + try { + return objectMapper.writeValueAsString(message); + } + catch (IOException e) { + throw Exceptions.propagate(e); + } + }).doOnNext(jsonText -> { + ServerSentEvent 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 unmarshalFrom(Object data, TypeReference typeRef) { + return objectMapper.convertValue(data, typeRef); + } + + @Override + public Mono 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 FluxMcpStreamableServerTransportProvider}. + *

+ * This builder provides a fluent API for configuring and creating instances of + * DefaultMcpStreamableServerTransportProvider with custom settings. + */ + public static class Builder { + + private ObjectMapper objectMapper; + + private McpTransportContextExtractor contextExtractor = ( + serverRequest) -> McpTransportContext.EMPTY; + + private boolean disallowDelete; + + private Duration keepAliveInterval; + + 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 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 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; + } + + /** + * Sets the keep-alive interval for the server transport. + * @param keepAliveInterval The interval for sending keep-alive messages. If null, + * no keep-alive will be scheduled. + * @return this builder instance + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + return this; + } + + /** + * Builds a new instance of {@link FluxMcpStreamableServerTransportProvider} with + * the configured settings. + * @return A new DefaultMcpStreamableServerTransportProvider instance + * @throws IllegalStateException if required parameters are not set + */ + public FluxMcpStreamableServerTransportProvider build() { + Assert.notNull(objectMapper, "ObjectMapper must be set"); + + return new FluxMcpStreamableServerTransportProvider(objectMapper, contextExtractor, + disallowDelete, keepAliveInterval); + } + + } + +} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java index 1417ff81c..27bd90c40 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java @@ -3,7 +3,6 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Component; import io.modelcontextprotocol.server.McpSyncServer; @@ -13,7 +12,7 @@ public class SdkMcpServer { private final McpSyncServer mcpSyncServer; - public SdkMcpServer(DefaultMcpStreamableServerTransportProvider transportProvider) { + public SdkMcpServer(FluxMcpStreamableServerTransportProvider transportProvider) { this.mcpSyncServer = McpServer.sync(transportProvider) .serverInfo("hkx-server", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder() diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java new file mode 100644 index 000000000..dbc403145 --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java @@ -0,0 +1,264 @@ +package modelengine.fel.tool.mcp.server; + +import modelengine.fitframework.inspection.Nullable; +import modelengine.fitframework.util.StringUtils; + +import java.time.Duration; +import java.util.Objects; + +public final class ServerSentEvent { + + @Nullable + private final String id; + + @Nullable + private final String event; + + @Nullable + private final Duration retry; + + @Nullable + private final String comment; + + @Nullable + private final T data; + + + private ServerSentEvent(@Nullable String id, @Nullable String event, @Nullable Duration retry, + @Nullable String comment, @Nullable T data) { + + this.id = id; + this.event = event; + this.retry = retry; + this.comment = comment; + this.data = data; + } + + + /** + * Return the {@code id} field of this event, if available. + */ + @Nullable + public String id() { + return this.id; + } + + /** + * Return the {@code event} field of this event, if available. + */ + @Nullable + public String event() { + return this.event; + } + + /** + * Return the {@code retry} field of this event, if available. + */ + @Nullable + public Duration retry() { + return this.retry; + } + + /** + * Return the comment of this event, if available. + */ + @Nullable + public String comment() { + return this.comment; + } + + /** + * Return the {@code data} field of this event, if available. + */ + @Nullable + public T data() { + return this.data; + } + + /** + * Return a StringBuilder with the id, event, retry, and comment fields fully + * serialized, and also appending "data:" if there is data. + * @since 6.2.1 + */ + public String format() { + StringBuilder sb = new StringBuilder(); + if (this.id != null) { + appendAttribute("id", this.id, sb); + } + if (this.event != null) { + appendAttribute("event", this.event, sb); + } + if (this.retry != null) { + appendAttribute("retry", this.retry.toMillis(), sb); + } + if (this.comment != null) { + sb.append(':').append(StringUtils.replace(this.comment, "\n", "\n:")).append('\n'); + } + if (this.data != null) { + sb.append("data:"); + } + return sb.toString(); + } + + private void appendAttribute(String fieldName, Object fieldValue, StringBuilder sb) { + sb.append(fieldName).append(':').append(fieldValue).append('\n'); + } + + @Override + public boolean equals(@Nullable Object other) { + // TODO implements nullSafeEquals like Spring's ObjectUtils.nullSafeEquals + return (this == other || (other instanceof ServerSentEvent that && + Objects.equals(this.id, that.id) && + Objects.equals(this.event, that.event) && + Objects.equals(this.retry, that.retry) && + Objects.equals(this.comment, that.comment) && + Objects.equals(this.data, that.data))); + } + + @Override + public int hashCode() { + // TODO implements nullSafeHash like Spring's ObjectUtils.nullSafeHashCode + return Objects.hash(this.id, this.event, this.retry, this.comment, this.data); + } + + @Override + public String toString() { + return ("ServerSentEvent [id = '" + this.id + "', event='" + this.event + "', retry=" + + this.retry + ", comment='" + this.comment + "', data=" + this.data + ']'); + } + + + /** + * Return a builder for a {@code ServerSentEvent}. + * @param the type of data that this event contains + * @return the builder + */ + public static Builder builder() { + return new BuilderImpl<>(); + } + + /** + * Return a builder for a {@code ServerSentEvent}, populated with the given {@linkplain #data() data}. + * @param the type of data that this event contains + * @return the builder + */ + public static Builder builder(T data) { + return new BuilderImpl<>(data); + } + + + /** + * A mutable builder for a {@code ServerSentEvent}. + * + * @param the type of data that this event contains + */ + public interface Builder { + + /** + * Set the value of the {@code id} field. + * @param id the value of the id field + * @return {@code this} builder + */ + Builder id(String id); + + /** + * Set the value of the {@code event} field. + * @param event the value of the event field + * @return {@code this} builder + */ + Builder event(String event); + + /** + * Set the value of the {@code retry} field. + * @param retry the value of the retry field + * @return {@code this} builder + */ + Builder retry(Duration retry); + + /** + * Set SSE comment. If a multi-line comment is provided, it will be turned into multiple + * SSE comment lines as defined in Server-Sent Events W3C recommendation. + * @param comment the comment to set + * @return {@code this} builder + */ + Builder comment(String comment); + + /** + * Set the value of the {@code data} field. If the {@code data} argument is a multi-line + * {@code String}, it will be turned into multiple {@code data} field lines as defined + * in the Server-Sent Events W3C recommendation. If {@code data} is not a String, it will + * be {@linkplain org.springframework.http.codec.json.Jackson2JsonEncoder encoded} into JSON. + * @param data the value of the data field + * @return {@code this} builder + */ + Builder data(@Nullable T data); + + /** + * Builds the event. + * @return the built event + */ + ServerSentEvent build(); + } + + + private static class BuilderImpl implements Builder { + + @Nullable + private String id; + + @Nullable + private String event; + + @Nullable + private Duration retry; + + @Nullable + private String comment; + + @Nullable + private T data; + + public BuilderImpl() { + } + + public BuilderImpl(T data) { + this.data = data; + } + + @Override + public Builder id(String id) { + this.id = id; + return this; + } + + @Override + public Builder event(String event) { + this.event = event; + return this; + } + + @Override + public Builder retry(Duration retry) { + this.retry = retry; + return this; + } + + @Override + public Builder comment(String comment) { + this.comment = comment; + return this; + } + + @Override + public Builder data(@Nullable T data) { + this.data = data; + return this; + } + + @Override + public ServerSentEvent build() { + return new ServerSentEvent<>(this.id, this.event, this.retry, this.comment, this.data); + } + } + +} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java new file mode 100644 index 000000000..c69f1a1b0 --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java @@ -0,0 +1,261 @@ +package modelengine.fel.tool.mcp.server.support; + +import modelengine.fit.http.protocol.HttpResponseStatus; +import modelengine.fitframework.inspection.Nullable; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +final class SseServerResponse extends AbstractServerResponse { + + private final Consumer sseConsumer; + + @Nullable + private final Duration timeout; + + + private SseServerResponse(Consumer sseConsumer, @Nullable Duration timeout) { + super(HttpResponseStatus.OK.statusCode(), createHeaders(), emptyCookies()); + this.sseConsumer = sseConsumer; + this.timeout = timeout; + } + + private static HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_EVENT_STREAM); + headers.setCacheControl(CacheControl.noCache()); + return headers; + } + + private static MultiValueMap emptyCookies() { + return CollectionUtils.toMultiValueMap(Collections.emptyMap()); + } + + + @Nullable + @Override + protected ModelAndView writeToInternal(HttpServletRequest request, HttpServletResponse response, + Context context) throws ServletException, IOException { + + DeferredResult result; + if (this.timeout != null) { + result = new DeferredResult<>(this.timeout.toMillis()); + } + else { + result = new DeferredResult<>(); + } + + DefaultAsyncServerResponse.writeAsync(request, response, result); + this.sseConsumer.accept(new DefaultSseBuilder(response, context, result, this.headers())); + return null; + } + + + public static ServerResponse create(Consumer sseConsumer, @Nullable Duration timeout) { + Assert.notNull(sseConsumer, "SseConsumer must not be null"); + + return new SseServerResponse(sseConsumer, timeout); + } + + + private static final class SseBuilder { + + private static final byte[] NL_NL = new byte[]{'\n', '\n'}; + + + private final ServerHttpResponse outputMessage; + + private final DeferredResult deferredResult; + + private final List> messageConverters; + + private final HttpHeaders httpHeaders; + + private final StringBuilder builder = new StringBuilder(); + + private boolean sendFailed; + + + public SseBuilder(HttpServletResponse response, Context context, DeferredResult deferredResult, + HttpHeaders httpHeaders) { + this.outputMessage = new ServletServerHttpResponse(response); + this.deferredResult = deferredResult; + this.messageConverters = context.messageConverters(); + this.httpHeaders = httpHeaders; + } + + @Override + public void send(Object object) throws IOException { + data(object); + } + + @Override + public void send() throws IOException { + this.builder.append('\n'); + try { + OutputStream body = this.outputMessage.getBody(); + body.write(builderBytes()); + body.flush(); + } + catch (IOException ex) { + this.sendFailed = true; + throw ex; + } + finally { + this.builder.setLength(0); + } + } + + @Override + public SseBuilder id(String id) { + Assert.hasLength(id, "Id must not be empty"); + return field("id", id); + } + + @Override + public SseBuilder event(String eventName) { + Assert.hasLength(eventName, "Name must not be empty"); + return field("event", eventName); + } + + @Override + public SseBuilder retry(Duration duration) { + Assert.notNull(duration, "Duration must not be null"); + String millis = Long.toString(duration.toMillis()); + return field("retry", millis); + } + + @Override + public SseBuilder comment(String comment) { + String[] lines = comment.split("\n"); + for (String line : lines) { + field("", line); + } + return this; + } + + private SseBuilder field(String name, String value) { + this.builder.append(name).append(':').append(value).append('\n'); + return this; + } + + @Override + public void data(Object object) throws IOException { + Assert.notNull(object, "Object must not be null"); + + if (object instanceof String text) { + writeString(text); + } + else { + writeObject(object); + } + } + + private void writeString(String string) throws IOException { + String[] lines = string.split("\n"); + for (String line : lines) { + field("data", line); + } + this.send(); + } + + @SuppressWarnings("unchecked") + private void writeObject(Object data) throws IOException { + this.builder.append("data:"); + try { + this.outputMessage.getBody().write(builderBytes()); + + Class dataClass = data.getClass(); + for (HttpMessageConverter converter : this.messageConverters) { + if (converter.canWrite(dataClass, MediaType.APPLICATION_JSON)) { + HttpMessageConverter objectConverter = (HttpMessageConverter) converter; + ServerHttpResponse response = new MutableHeadersServerHttpResponse(this.outputMessage, this.httpHeaders); + objectConverter.write(data, MediaType.APPLICATION_JSON, response); + this.outputMessage.getBody().write(NL_NL); + this.outputMessage.flush(); + return; + } + } + } + catch (IOException ex) { + this.sendFailed = true; + throw ex; + } + finally { + this.builder.setLength(0); + } + } + + private byte[] builderBytes() { + return this.builder.toString().getBytes(StandardCharsets.UTF_8); + } + + @Override + public void error(Throwable t) { + if (this.sendFailed) { + return; + } + this.deferredResult.setErrorResult(t); + } + + @Override + public void complete() { + if (this.sendFailed) { + return; + } + try { + this.outputMessage.flush(); + this.deferredResult.setResult(null); + } + catch (IOException ex) { + this.deferredResult.setErrorResult(ex); + } + } + + @Override + public SseBuilder onTimeout(Runnable onTimeout) { + this.deferredResult.onTimeout(onTimeout); + return this; + } + + @Override + public SseBuilder onError(Consumer onError) { + this.deferredResult.onError(onError); + return this; + } + + @Override + public SseBuilder onComplete(Runnable onCompletion) { + this.deferredResult.onCompletion(onCompletion); + return this; + } + + + /** + * Wrap to silently ignore header changes HttpMessageConverter's that would + * otherwise cause HttpHeaders to raise exceptions. + */ + private static final class MutableHeadersServerHttpResponse extends DelegatingServerHttpResponse { + + private final HttpHeaders mutableHeaders = new HttpHeaders(); + + public MutableHeadersServerHttpResponse(ServerHttpResponse delegate, HttpHeaders headers) { + super(delegate); + this.mutableHeaders.putAll(delegate.getHeaders()); + this.mutableHeaders.putAll(headers); + } + + @Override + public HttpHeaders getHeaders() { + return this.mutableHeaders; + } + + } + + } +} \ No newline at end of file From 1ffc318e5d1982487ac2f3b6d89337e40e32a48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Tue, 30 Sep 2025 14:45:26 +0800 Subject: [PATCH 03/37] =?UTF-8?q?=E4=BD=BF=E7=94=A8Choir=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E6=B5=81=E5=AE=9E=E7=8E=B0sse=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tMcpStreamableServerTransportProvider.java | 118 ++++---- .../mcp/server/support/SseServerResponse.java | 261 ------------------ 2 files changed, 69 insertions(+), 310 deletions(-) delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java index f5271ec09..777720fc3 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java @@ -7,6 +7,8 @@ import io.modelcontextprotocol.spec.*; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; +import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fit.http.entity.TextEvent; import modelengine.fit.http.annotation.*; import modelengine.fit.http.entity.Entity; import modelengine.fit.http.protocol.HttpResponseStatus; @@ -14,6 +16,8 @@ import modelengine.fit.http.protocol.MimeType; import modelengine.fit.http.server.HttpClassicServerRequest; import modelengine.fit.http.server.HttpClassicServerResponse; +import modelengine.fitframework.flowable.Choir; +import modelengine.fitframework.flowable.Emitter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; @@ -22,8 +26,6 @@ import java.io.IOException; import java.time.Duration; import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -171,18 +173,18 @@ public Mono closeGracefully() { * @param request The incoming server request */ @GetMapping - private void handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { + private Choir handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); response.entity(Entity.createText(response, "Server is shutting down")); - return; + return Choir.empty(); } List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value())) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); response.entity(Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM")); - return; + return Choir.empty(); } McpTransportContext transportContext = this.contextExtractor.extract(request); @@ -190,7 +192,7 @@ private void handleGet(HttpClassicServerRequest request, HttpClassicServerRespon if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); response.entity(Entity.createText(response, "Session ID required in mcp-session-id header")); - return; + return Choir.empty(); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -198,20 +200,20 @@ private void handleGet(HttpClassicServerRequest request, HttpClassicServerRespon if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return; + return Choir.empty(); } logger.debug("Handling GET request for session: {}", sessionId); try { - - return HttpClassicServerResponse.sse(sseBuilder -> { - sseBuilder.onTimeout(() -> { - logger.debug("SSE connection timed out for session: {}", sessionId); - }); + return Choir.create(emitter -> { + // TODO onTimeout() +// emitter.onTimeout(() -> { +// logger.debug("SSE connection timed out for session: {}", sessionId); +// }); DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( - sessionId, sseBuilder); + sessionId, emitter); // Check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { @@ -229,13 +231,13 @@ private void handleGet(HttpClassicServerRequest request, HttpClassicServerRespon } catch (Exception e) { logger.error("Failed to replay message: {}", e.getMessage()); - sseBuilder.error(e); + emitter.fail(e); } }); } catch (Exception e) { logger.error("Failed to replay messages: {}", e.getMessage()); - sseBuilder.error(e); + emitter.fail(e); } } else { @@ -243,16 +245,30 @@ private void handleGet(HttpClassicServerRequest request, HttpClassicServerRespon McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session .listeningStream(sessionTransport); - sseBuilder.onComplete(() -> { - logger.debug("SSE connection completed for session: {}", sessionId); - listeningStream.close(); + emitter.observe(new Emitter.Observer() { + @Override + public void onEmittedData(TextEvent data) { + // No action needed on emitted data + } + + @Override + public void onCompleted() { + logger.debug("SSE connection completed for session: {}", sessionId); + listeningStream.close(); + } + + @Override + public void onFailed(Exception cause) { + // Close the listening stream on failure + } }); } - }, Duration.ZERO); + }); } catch (Exception e) { logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + return Choir.empty(); } } @@ -261,11 +277,11 @@ private void handleGet(HttpClassicServerRequest request, HttpClassicServerRespon * @param request The incoming server request containing the JSON-RPC message */ @PostMapping - private void handlePost(@RequestBody String body,HttpClassicServerRequest request, HttpClassicServerResponse response) { + private Choir handlePost(@RequestBody String body,HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); response.entity(Entity.createText(response, "Server is shutting down")); - return; + return Choir.empty(); } List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); @@ -273,7 +289,7 @@ private void handlePost(@RequestBody String body,HttpClassicServerRequest reques || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); response.entity(Entity.createObject(response, new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON"))); - return; + return Choir.empty(); } McpTransportContext transportContext = this.contextExtractor.extract(request); @@ -299,13 +315,13 @@ private void handlePost(@RequestBody String body,HttpClassicServerRequest reques response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); response.entity(Entity.createObject(response, new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null))); - return; + return Choir.empty(); } catch (Exception e) { logger.error("Failed to initialize session: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); response.entity(Entity.createObject(response, new McpError(e.getMessage()))); - return; + return Choir.empty(); } } @@ -313,7 +329,7 @@ private void handlePost(@RequestBody String body,HttpClassicServerRequest reques if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); response.entity(Entity.createObject(response, new McpError("Session ID missing"))); - return; + return Choir.empty(); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -322,7 +338,7 @@ private void handlePost(@RequestBody String body,HttpClassicServerRequest reques if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); response.entity(Entity.createObject(response, new McpError("Session not found: " + sessionId))); - return; + return Choir.empty(); } if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { @@ -330,51 +346,56 @@ private void handlePost(@RequestBody String body,HttpClassicServerRequest reques .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); + return Choir.empty(); + } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { session.accept(jsonrpcNotification) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); + return Choir.empty(); } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { // For streaming responses, we need to return SSE - return HttpClassicServerResponse.sse(sseBuilder -> { - sseBuilder.onComplete(() -> { - logger.debug("Request response stream completed for session: {}", sessionId); - }); - sseBuilder.onTimeout(() -> { - logger.debug("Request response stream timed out for session: {}", sessionId); - }); + return Choir.create(emitter -> { + // TODO onComplete() and onTimeout() +// emitter.onComplete(() -> { +// logger.debug("Request response stream completed for session: {}", sessionId); +// }); +// emitter.onTimeout(() -> { +// logger.debug("Request response stream timed out for session: {}", sessionId); +// }); - DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( - sessionId, sseBuilder); + DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport(sessionId, emitter); try { session.responseStream(jsonrpcRequest, sessionTransport) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); - } - catch (Exception e) { + logger.debug("Request response stream completed for session: {}", sessionId); + } catch (Exception e) { logger.error("Failed to handle request stream: {}", e.getMessage()); - sseBuilder.error(e); - } - }, Duration.ZERO); + emitter.fail(e); + }}); } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); response.entity(Entity.createObject(response, new McpError("Unknown message type"))); + return Choir.empty(); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); response.entity(Entity.createObject(response, new McpError("Invalid message format"))); + return Choir.empty(); } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); response.entity(Entity.createObject(response, new McpError(e.getMessage()))); + return Choir.empty(); } } @@ -436,7 +457,7 @@ private class DefaultStreamableMcpSessionTransport implements McpStreamableServe private final String sessionId; - private final SseBuilder sseBuilder; + private final Emitter emitter; private final ReentrantLock lock = new ReentrantLock(); @@ -445,11 +466,10 @@ private class DefaultStreamableMcpSessionTransport implements McpStreamableServe /** * Creates a new session transport with the specified ID and SSE builder. * @param sessionId The unique identifier for this session - * @param sseBuilder The SSE builder for sending server events to the client */ - DefaultStreamableMcpSessionTransport(String sessionId, SseBuilder sseBuilder) { + DefaultStreamableMcpSessionTransport(String sessionId, Emitter emitter) { this.sessionId = sessionId; - this.sseBuilder = sseBuilder; + this.emitter = emitter; logger.debug("Streamable session transport {} initialized with SSE builder", sessionId); } @@ -486,15 +506,15 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId } String jsonText = objectMapper.writeValueAsString(message); - this.sseBuilder.id(messageId != null ? messageId : this.sessionId) - .event(MESSAGE_EVENT_TYPE) - .data(jsonText); + TextEvent textEvent = TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); + this.emitter.emit(textEvent); + logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId); } catch (Exception e) { logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); try { - this.sseBuilder.error(e); + this.emitter.fail(e); } catch (Exception errorException) { logger.error("Failed to send error to SSE builder for session {}: {}", this.sessionId, @@ -544,7 +564,7 @@ public void close() { this.closed = true; - this.sseBuilder.complete(); + this.emitter.complete(); logger.debug("Successfully completed SSE builder for session {}", sessionId); } catch (Exception e) { diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java deleted file mode 100644 index c69f1a1b0..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/SseServerResponse.java +++ /dev/null @@ -1,261 +0,0 @@ -package modelengine.fel.tool.mcp.server.support; - -import modelengine.fit.http.protocol.HttpResponseStatus; -import modelengine.fitframework.inspection.Nullable; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Collections; -import java.util.List; -import java.util.function.Consumer; - -final class SseServerResponse extends AbstractServerResponse { - - private final Consumer sseConsumer; - - @Nullable - private final Duration timeout; - - - private SseServerResponse(Consumer sseConsumer, @Nullable Duration timeout) { - super(HttpResponseStatus.OK.statusCode(), createHeaders(), emptyCookies()); - this.sseConsumer = sseConsumer; - this.timeout = timeout; - } - - private static HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_EVENT_STREAM); - headers.setCacheControl(CacheControl.noCache()); - return headers; - } - - private static MultiValueMap emptyCookies() { - return CollectionUtils.toMultiValueMap(Collections.emptyMap()); - } - - - @Nullable - @Override - protected ModelAndView writeToInternal(HttpServletRequest request, HttpServletResponse response, - Context context) throws ServletException, IOException { - - DeferredResult result; - if (this.timeout != null) { - result = new DeferredResult<>(this.timeout.toMillis()); - } - else { - result = new DeferredResult<>(); - } - - DefaultAsyncServerResponse.writeAsync(request, response, result); - this.sseConsumer.accept(new DefaultSseBuilder(response, context, result, this.headers())); - return null; - } - - - public static ServerResponse create(Consumer sseConsumer, @Nullable Duration timeout) { - Assert.notNull(sseConsumer, "SseConsumer must not be null"); - - return new SseServerResponse(sseConsumer, timeout); - } - - - private static final class SseBuilder { - - private static final byte[] NL_NL = new byte[]{'\n', '\n'}; - - - private final ServerHttpResponse outputMessage; - - private final DeferredResult deferredResult; - - private final List> messageConverters; - - private final HttpHeaders httpHeaders; - - private final StringBuilder builder = new StringBuilder(); - - private boolean sendFailed; - - - public SseBuilder(HttpServletResponse response, Context context, DeferredResult deferredResult, - HttpHeaders httpHeaders) { - this.outputMessage = new ServletServerHttpResponse(response); - this.deferredResult = deferredResult; - this.messageConverters = context.messageConverters(); - this.httpHeaders = httpHeaders; - } - - @Override - public void send(Object object) throws IOException { - data(object); - } - - @Override - public void send() throws IOException { - this.builder.append('\n'); - try { - OutputStream body = this.outputMessage.getBody(); - body.write(builderBytes()); - body.flush(); - } - catch (IOException ex) { - this.sendFailed = true; - throw ex; - } - finally { - this.builder.setLength(0); - } - } - - @Override - public SseBuilder id(String id) { - Assert.hasLength(id, "Id must not be empty"); - return field("id", id); - } - - @Override - public SseBuilder event(String eventName) { - Assert.hasLength(eventName, "Name must not be empty"); - return field("event", eventName); - } - - @Override - public SseBuilder retry(Duration duration) { - Assert.notNull(duration, "Duration must not be null"); - String millis = Long.toString(duration.toMillis()); - return field("retry", millis); - } - - @Override - public SseBuilder comment(String comment) { - String[] lines = comment.split("\n"); - for (String line : lines) { - field("", line); - } - return this; - } - - private SseBuilder field(String name, String value) { - this.builder.append(name).append(':').append(value).append('\n'); - return this; - } - - @Override - public void data(Object object) throws IOException { - Assert.notNull(object, "Object must not be null"); - - if (object instanceof String text) { - writeString(text); - } - else { - writeObject(object); - } - } - - private void writeString(String string) throws IOException { - String[] lines = string.split("\n"); - for (String line : lines) { - field("data", line); - } - this.send(); - } - - @SuppressWarnings("unchecked") - private void writeObject(Object data) throws IOException { - this.builder.append("data:"); - try { - this.outputMessage.getBody().write(builderBytes()); - - Class dataClass = data.getClass(); - for (HttpMessageConverter converter : this.messageConverters) { - if (converter.canWrite(dataClass, MediaType.APPLICATION_JSON)) { - HttpMessageConverter objectConverter = (HttpMessageConverter) converter; - ServerHttpResponse response = new MutableHeadersServerHttpResponse(this.outputMessage, this.httpHeaders); - objectConverter.write(data, MediaType.APPLICATION_JSON, response); - this.outputMessage.getBody().write(NL_NL); - this.outputMessage.flush(); - return; - } - } - } - catch (IOException ex) { - this.sendFailed = true; - throw ex; - } - finally { - this.builder.setLength(0); - } - } - - private byte[] builderBytes() { - return this.builder.toString().getBytes(StandardCharsets.UTF_8); - } - - @Override - public void error(Throwable t) { - if (this.sendFailed) { - return; - } - this.deferredResult.setErrorResult(t); - } - - @Override - public void complete() { - if (this.sendFailed) { - return; - } - try { - this.outputMessage.flush(); - this.deferredResult.setResult(null); - } - catch (IOException ex) { - this.deferredResult.setErrorResult(ex); - } - } - - @Override - public SseBuilder onTimeout(Runnable onTimeout) { - this.deferredResult.onTimeout(onTimeout); - return this; - } - - @Override - public SseBuilder onError(Consumer onError) { - this.deferredResult.onError(onError); - return this; - } - - @Override - public SseBuilder onComplete(Runnable onCompletion) { - this.deferredResult.onCompletion(onCompletion); - return this; - } - - - /** - * Wrap to silently ignore header changes HttpMessageConverter's that would - * otherwise cause HttpHeaders to raise exceptions. - */ - private static final class MutableHeadersServerHttpResponse extends DelegatingServerHttpResponse { - - private final HttpHeaders mutableHeaders = new HttpHeaders(); - - public MutableHeadersServerHttpResponse(ServerHttpResponse delegate, HttpHeaders headers) { - super(delegate); - this.mutableHeaders.putAll(delegate.getHeaders()); - this.mutableHeaders.putAll(headers); - } - - @Override - public HttpHeaders getHeaders() { - return this.mutableHeaders; - } - - } - - } -} \ No newline at end of file From 3be1b935144d3dcc2ec47419bad60f69c8e398b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Tue, 30 Sep 2025 15:11:10 +0800 Subject: [PATCH 04/37] =?UTF-8?q?=E6=B8=85=E9=99=A4=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...xMcpStreamableServerTransportProvider.java | 447 ------------------ .../fel/tool/mcp/server/ServerSentEvent.java | 264 ----------- ...tMcpStreamableServerTransportProvider.java | 2 +- .../DefaultStreamableSyncMcpServer.java} | 10 +- 4 files changed, 8 insertions(+), 715 deletions(-) delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FluxMcpStreamableServerTransportProvider.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{ => support}/DefaultMcpStreamableServerTransportProvider.java (99%) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{SdkMcpServer.java => support/DefaultStreamableSyncMcpServer.java} (70%) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FluxMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FluxMcpStreamableServerTransportProvider.java deleted file mode 100644 index 064cb372d..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FluxMcpStreamableServerTransportProvider.java +++ /dev/null @@ -1,447 +0,0 @@ -package modelengine.fel.tool.mcp.server; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.spec.*; -import io.modelcontextprotocol.util.Assert; -import io.modelcontextprotocol.util.KeepAliveScheduler; -import modelengine.fit.http.annotation.DeleteMapping; -import modelengine.fit.http.annotation.GetMapping; -import modelengine.fit.http.annotation.PostMapping; -import modelengine.fit.http.annotation.RequestMapping; -import modelengine.fit.http.protocol.HttpResponseStatus; -import modelengine.fit.http.server.HttpClassicServerRequest; -import modelengine.fit.http.server.HttpClassicServerResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -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.time.Duration; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; - -@RequestMapping("/mcp/streamable") -public class FluxMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { - - private static final Logger logger = LoggerFactory.getLogger(FluxMcpStreamableServerTransportProvider.class); - - public static final String MESSAGE_EVENT_TYPE = "message"; - - private final ObjectMapper objectMapper; - - private final boolean disallowDelete; - - private McpStreamableServerSession.Factory sessionFactory; - - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - - private McpTransportContextExtractor contextExtractor; - - private volatile boolean isClosing = false; - - private KeepAliveScheduler keepAliveScheduler; - - private FluxMcpStreamableServerTransportProvider(ObjectMapper objectMapper, - McpTransportContextExtractor contextExtractor, boolean disallowDelete, - Duration keepAliveInterval) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - Assert.notNull(contextExtractor, "Context extractor must not be null"); - - this.objectMapper = objectMapper; - this.contextExtractor = contextExtractor; - this.disallowDelete = disallowDelete; - - if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler - .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(this.sessions.values())) - .initialDelay(keepAliveInterval) - .interval(keepAliveInterval) - .build(); - - this.keepAliveScheduler.start(); - } - } - - @Override - public List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26); - } - - @Override - public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { - this.sessionFactory = sessionFactory; - } - - @Override - public Mono 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 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(); - }).then().doOnSuccess(v -> { - sessions.clear(); - if (this.keepAliveScheduler != null) { - this.keepAliveScheduler.shutdown(); - } - }); - } - - /** - * Opens the listening SSE streams for clients. - * @param request The incoming server request - */ - @GetMapping - private void handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { - if (isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - response.reasonPhrase("Server is shutting down"); - return; - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - return Mono.defer(() -> { - String acceptHeader = request.headers().first("Accept"); - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM)) { - return HttpClassicServerResponse.badRequest().build(); - } - - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { - return HttpClassicServerResponse.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 HttpClassicServerResponse.notFound().build(); - } - - if (request.headers().asHttpHeaders().containsKey(HttpHeaders.LAST_EVENT_ID)) { - String lastId = request.headers().asHttpHeaders().getFirst(HttpHeaders.LAST_EVENT_ID); - return HttpClassicServerResponse.ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(session.replay(lastId) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), - ServerSentEvent.class); - } - - return HttpClassicServerResponse.ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(Flux.>create(sink -> { - FluxStreamableMcpSessionTransport sessionTransport = new FluxStreamableMcpSessionTransport( - sink); - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session - .listeningStream(sessionTransport); - sink.onDispose(listeningStream::close); - // TODO Clarify why the outer context is not present in the - // Flux.create sink? - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), 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. - */ - @PostMapping - private void handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response) { - if (isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - response.reasonPhrase("Server is shutting down"); - return; - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - List acceptHeaders = request.headers().asHttpHeaders().getAccept(); - if (!(acceptHeaders.contains(MediaType.APPLICATION_JSON) - && acceptHeaders.contains(MediaType.TEXT_EVENT_STREAM))) { - return HttpClassicServerResponse.badRequest().build(); - } - - return request.bodyToMono(String.class).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() { - }); - 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 -> HttpClassicServerResponse.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 HttpClassicServerResponse.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 HttpClassicServerResponse.status(HttpStatus.NOT_FOUND) - .bodyValue(new McpError("Session not found: " + sessionId)); - } - - if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - return session.accept(jsonrpcResponse).then(HttpClassicServerResponse.accepted().build()); - } - else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - return session.accept(jsonrpcNotification).then(HttpClassicServerResponse.accepted().build()); - } - else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - return HttpClassicServerResponse.ok() - .contentType(MediaType.TEXT_EVENT_STREAM) - .body(Flux.>create(sink -> { - FluxStreamableMcpSessionTransport st = new FluxStreamableMcpSessionTransport(sink); - Mono stream = session.responseStream(jsonrpcRequest, st); - Disposable streamSubscription = stream.onErrorComplete(err -> { - sink.error(err); - return true; - }).contextWrite(sink.contextView()).subscribe(); - sink.onCancel(streamSubscription); - // TODO Clarify why the outer context is not present in the - // Flux.create sink? - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), - ServerSentEvent.class); - } - else { - return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Unknown message type")); - } - } - catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); - return HttpClassicServerResponse.badRequest().bodyValue(new McpError("Invalid message format")); - } - }) - .switchIfEmpty(HttpClassicServerResponse.badRequest().build()) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); - } - - @DeleteMapping - private void handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { - if (isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - response.reasonPhrase("Server is shutting down"); - return; - } - - McpTransportContext transportContext = this.contextExtractor.extract(request); - - return Mono.defer(() -> { - if (!request.headers().asHttpHeaders().containsKey(HttpHeaders.MCP_SESSION_ID)) { - return HttpClassicServerResponse.badRequest().build(); // TODO: say we need a session - // id - } - - if (this.disallowDelete) { - return HttpClassicServerResponse.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 HttpClassicServerResponse.notFound().build(); - } - - return session.delete().then(HttpClassicServerResponse.ok().build()); - }).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)); - } - - private class FluxStreamableMcpSessionTransport implements McpStreamableServerTransport { - - private final FluxSink> sink; - - public FluxStreamableMcpSessionTransport(FluxSink> sink) { - this.sink = sink; - } - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return this.sendMessage(message, null); - } - - - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { - return Mono.fromSupplier(() -> { - try { - return objectMapper.writeValueAsString(message); - } - catch (IOException e) { - throw Exceptions.propagate(e); - } - }).doOnNext(jsonText -> { - ServerSentEvent 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 unmarshalFrom(Object data, TypeReference typeRef) { - return objectMapper.convertValue(data, typeRef); - } - - @Override - public Mono 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 FluxMcpStreamableServerTransportProvider}. - *

- * This builder provides a fluent API for configuring and creating instances of - * DefaultMcpStreamableServerTransportProvider with custom settings. - */ - public static class Builder { - - private ObjectMapper objectMapper; - - private McpTransportContextExtractor contextExtractor = ( - serverRequest) -> McpTransportContext.EMPTY; - - private boolean disallowDelete; - - private Duration keepAliveInterval; - - 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 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 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; - } - - /** - * Sets the keep-alive interval for the server transport. - * @param keepAliveInterval The interval for sending keep-alive messages. If null, - * no keep-alive will be scheduled. - * @return this builder instance - */ - public Builder keepAliveInterval(Duration keepAliveInterval) { - this.keepAliveInterval = keepAliveInterval; - return this; - } - - /** - * Builds a new instance of {@link FluxMcpStreamableServerTransportProvider} with - * the configured settings. - * @return A new DefaultMcpStreamableServerTransportProvider instance - * @throws IllegalStateException if required parameters are not set - */ - public FluxMcpStreamableServerTransportProvider build() { - Assert.notNull(objectMapper, "ObjectMapper must be set"); - - return new FluxMcpStreamableServerTransportProvider(objectMapper, contextExtractor, - disallowDelete, keepAliveInterval); - } - - } - -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java deleted file mode 100644 index dbc403145..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/ServerSentEvent.java +++ /dev/null @@ -1,264 +0,0 @@ -package modelengine.fel.tool.mcp.server; - -import modelengine.fitframework.inspection.Nullable; -import modelengine.fitframework.util.StringUtils; - -import java.time.Duration; -import java.util.Objects; - -public final class ServerSentEvent { - - @Nullable - private final String id; - - @Nullable - private final String event; - - @Nullable - private final Duration retry; - - @Nullable - private final String comment; - - @Nullable - private final T data; - - - private ServerSentEvent(@Nullable String id, @Nullable String event, @Nullable Duration retry, - @Nullable String comment, @Nullable T data) { - - this.id = id; - this.event = event; - this.retry = retry; - this.comment = comment; - this.data = data; - } - - - /** - * Return the {@code id} field of this event, if available. - */ - @Nullable - public String id() { - return this.id; - } - - /** - * Return the {@code event} field of this event, if available. - */ - @Nullable - public String event() { - return this.event; - } - - /** - * Return the {@code retry} field of this event, if available. - */ - @Nullable - public Duration retry() { - return this.retry; - } - - /** - * Return the comment of this event, if available. - */ - @Nullable - public String comment() { - return this.comment; - } - - /** - * Return the {@code data} field of this event, if available. - */ - @Nullable - public T data() { - return this.data; - } - - /** - * Return a StringBuilder with the id, event, retry, and comment fields fully - * serialized, and also appending "data:" if there is data. - * @since 6.2.1 - */ - public String format() { - StringBuilder sb = new StringBuilder(); - if (this.id != null) { - appendAttribute("id", this.id, sb); - } - if (this.event != null) { - appendAttribute("event", this.event, sb); - } - if (this.retry != null) { - appendAttribute("retry", this.retry.toMillis(), sb); - } - if (this.comment != null) { - sb.append(':').append(StringUtils.replace(this.comment, "\n", "\n:")).append('\n'); - } - if (this.data != null) { - sb.append("data:"); - } - return sb.toString(); - } - - private void appendAttribute(String fieldName, Object fieldValue, StringBuilder sb) { - sb.append(fieldName).append(':').append(fieldValue).append('\n'); - } - - @Override - public boolean equals(@Nullable Object other) { - // TODO implements nullSafeEquals like Spring's ObjectUtils.nullSafeEquals - return (this == other || (other instanceof ServerSentEvent that && - Objects.equals(this.id, that.id) && - Objects.equals(this.event, that.event) && - Objects.equals(this.retry, that.retry) && - Objects.equals(this.comment, that.comment) && - Objects.equals(this.data, that.data))); - } - - @Override - public int hashCode() { - // TODO implements nullSafeHash like Spring's ObjectUtils.nullSafeHashCode - return Objects.hash(this.id, this.event, this.retry, this.comment, this.data); - } - - @Override - public String toString() { - return ("ServerSentEvent [id = '" + this.id + "', event='" + this.event + "', retry=" + - this.retry + ", comment='" + this.comment + "', data=" + this.data + ']'); - } - - - /** - * Return a builder for a {@code ServerSentEvent}. - * @param the type of data that this event contains - * @return the builder - */ - public static Builder builder() { - return new BuilderImpl<>(); - } - - /** - * Return a builder for a {@code ServerSentEvent}, populated with the given {@linkplain #data() data}. - * @param the type of data that this event contains - * @return the builder - */ - public static Builder builder(T data) { - return new BuilderImpl<>(data); - } - - - /** - * A mutable builder for a {@code ServerSentEvent}. - * - * @param the type of data that this event contains - */ - public interface Builder { - - /** - * Set the value of the {@code id} field. - * @param id the value of the id field - * @return {@code this} builder - */ - Builder id(String id); - - /** - * Set the value of the {@code event} field. - * @param event the value of the event field - * @return {@code this} builder - */ - Builder event(String event); - - /** - * Set the value of the {@code retry} field. - * @param retry the value of the retry field - * @return {@code this} builder - */ - Builder retry(Duration retry); - - /** - * Set SSE comment. If a multi-line comment is provided, it will be turned into multiple - * SSE comment lines as defined in Server-Sent Events W3C recommendation. - * @param comment the comment to set - * @return {@code this} builder - */ - Builder comment(String comment); - - /** - * Set the value of the {@code data} field. If the {@code data} argument is a multi-line - * {@code String}, it will be turned into multiple {@code data} field lines as defined - * in the Server-Sent Events W3C recommendation. If {@code data} is not a String, it will - * be {@linkplain org.springframework.http.codec.json.Jackson2JsonEncoder encoded} into JSON. - * @param data the value of the data field - * @return {@code this} builder - */ - Builder data(@Nullable T data); - - /** - * Builds the event. - * @return the built event - */ - ServerSentEvent build(); - } - - - private static class BuilderImpl implements Builder { - - @Nullable - private String id; - - @Nullable - private String event; - - @Nullable - private Duration retry; - - @Nullable - private String comment; - - @Nullable - private T data; - - public BuilderImpl() { - } - - public BuilderImpl(T data) { - this.data = data; - } - - @Override - public Builder id(String id) { - this.id = id; - return this; - } - - @Override - public Builder event(String event) { - this.event = event; - return this; - } - - @Override - public Builder retry(Duration retry) { - this.retry = retry; - return this; - } - - @Override - public Builder comment(String comment) { - this.comment = comment; - return this; - } - - @Override - public Builder data(@Nullable T data) { - this.data = data; - return this; - } - - @Override - public ServerSentEvent build() { - return new ServerSentEvent<>(this.id, this.event, this.retry, this.comment, this.data); - } - } - -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java similarity index 99% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index 777720fc3..a8a863a83 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -1,4 +1,4 @@ -package modelengine.fel.tool.mcp.server; +package modelengine.fel.tool.mcp.server.support; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java similarity index 70% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java index 27bd90c40..a062a17d0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/SdkMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java @@ -1,6 +1,7 @@ -package modelengine.fel.tool.mcp.server; +package modelengine.fel.tool.mcp.server.support; +import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fitframework.annotation.Component; @@ -9,10 +10,13 @@ import java.time.Duration; @Component -public class SdkMcpServer { +public class DefaultStreamableSyncMcpServer { private final McpSyncServer mcpSyncServer; - public SdkMcpServer(FluxMcpStreamableServerTransportProvider transportProvider) { + public DefaultStreamableSyncMcpServer(ObjectMapper mapper) { + DefaultMcpStreamableServerTransportProvider transportProvider = DefaultMcpStreamableServerTransportProvider.builder() + .objectMapper(mapper) + .build(); this.mcpSyncServer = McpServer.sync(transportProvider) .serverInfo("hkx-server", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder() From 0da5c4330da6b5e6b0560a4334a33fa1e38977f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Tue, 30 Sep 2025 17:36:55 +0800 Subject: [PATCH 05/37] =?UTF-8?q?request.headers().all()=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=9C=89bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tMcpStreamableServerTransportProvider.java | 29 ++++++++++++------- .../DefaultStreamableSyncMcpServer.java | 19 +++++++----- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index a8a863a83..f40a50be8 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -8,6 +8,7 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fel.tool.mcp.server.McpServerController; import modelengine.fit.http.entity.TextEvent; import modelengine.fit.http.annotation.*; import modelengine.fit.http.entity.Entity; @@ -18,21 +19,22 @@ import modelengine.fit.http.server.HttpClassicServerResponse; import modelengine.fitframework.flowable.Choir; import modelengine.fitframework.flowable.Emitter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import modelengine.fitframework.log.Logger; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.io.IOException; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; -@RequestMapping("/mcp/streamable") public class DefaultMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { - private static final Logger logger = LoggerFactory.getLogger(DefaultMcpStreamableServerTransportProvider.class); + private static final Logger logger = Logger.get(McpServerController.class); + + private static final String MESSAGE_ENDPOINT = "/mcp/streamable"; /** * Event type for JSON-RPC messages sent through the SSE connection. @@ -172,8 +174,8 @@ public Mono closeGracefully() { * Setup the listening SSE connections and message replay. * @param request The incoming server request */ - @GetMapping - private Choir handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { + @GetMapping(path = MESSAGE_ENDPOINT) + public Choir handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); response.entity(Entity.createText(response, "Server is shutting down")); @@ -276,9 +278,12 @@ public void onFailed(Exception cause) { * Handles POST requests for incoming JSON-RPC messages from clients. * @param request The incoming server request containing the JSON-RPC message */ - @PostMapping - private Choir handlePost(@RequestBody String body,HttpClassicServerRequest request, HttpClassicServerResponse response) { + @PostMapping(path = MESSAGE_ENDPOINT) + public Choir handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response) { + String body = response.entity().toString(); + System.out.println("111111111111111111: "+ body); if (this.isClosing) { + System.out.println("2"); response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); response.entity(Entity.createText(response, "Server is shutting down")); return Choir.empty(); @@ -287,15 +292,17 @@ private Choir handlePost(@RequestBody String body,HttpClassicServerRe List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value()) || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { + System.out.println("3"); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); response.entity(Entity.createObject(response, new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON"))); return Choir.empty(); } McpTransportContext transportContext = this.contextExtractor.extract(request); - + System.out.println("4"); try { + System.out.println("5"); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body); // Handle initialization request @@ -403,8 +410,8 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { * Handles DELETE requests for session deletion. * @param request The incoming server request */ - @DeleteMapping - private void handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { + @DeleteMapping(path = MESSAGE_ENDPOINT) + public void handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); response.entity(Entity.createText(response, "Server is shutting down")); diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java index a062a17d0..93fa94c59 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java @@ -4,21 +4,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; import io.modelcontextprotocol.server.McpSyncServer; +import modelengine.fitframework.annotation.ImportConfigs; import java.time.Duration; @Component public class DefaultStreamableSyncMcpServer { - private final McpSyncServer mcpSyncServer; - - public DefaultStreamableSyncMcpServer(ObjectMapper mapper) { - DefaultMcpStreamableServerTransportProvider transportProvider = DefaultMcpStreamableServerTransportProvider.builder() - .objectMapper(mapper) + @Bean + public DefaultMcpStreamableServerTransportProvider defaultMcpStreamableServerTransportProvider() { + return DefaultMcpStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) .build(); - this.mcpSyncServer = McpServer.sync(transportProvider) - .serverInfo("hkx-server", "1.0.0") + } + + @Bean + public McpSyncServer mcpSyncServer(DefaultMcpStreamableServerTransportProvider transportProvider) { + return McpServer.sync(transportProvider) + .serverInfo("fit-mcp-streamable-server", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder() .resources(false, true) // Enable resource support .tools(true) // Enable tool support From b1bf920763766a54dcd4c0d93ebcb1a10ec16157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Mon, 13 Oct 2025 14:48:29 +0800 Subject: [PATCH 06/37] =?UTF-8?q?=E6=8E=A5=E5=85=A5MCP=20SDK=E7=9A=84strea?= =?UTF-8?q?mable=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tMcpStreamableServerTransportProvider.java | 108 +++++++++--------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index f40a50be8..807ac94a4 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -8,6 +8,7 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fel.tool.mcp.entity.JsonRpc; import modelengine.fel.tool.mcp.server.McpServerController; import modelengine.fit.http.entity.TextEvent; import modelengine.fit.http.annotation.*; @@ -20,6 +21,7 @@ import modelengine.fitframework.flowable.Choir; import modelengine.fitframework.flowable.Emitter; import modelengine.fitframework.log.Logger; +import modelengine.fitframework.serialization.ObjectSerializer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -65,7 +67,7 @@ public class DefaultMcpStreamableServerTransportProvider implements McpStreamabl */ private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - private McpTransportContextExtractor contextExtractor; + private final McpTransportContextExtractor contextExtractor; /** * Flag indicating if the transport is shutting down. @@ -171,30 +173,27 @@ public Mono closeGracefully() { /** - * Setup the listening SSE connections and message replay. + * Set up the listening SSE connections and message replay. * @param request The incoming server request */ @GetMapping(path = MESSAGE_ENDPOINT) - public Choir handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { + public Object handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - response.entity(Entity.createText(response, "Server is shutting down")); - return Choir.empty(); + return Entity.createText(response, "Server is shutting down"); } - List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); + String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value())) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - response.entity(Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM")); - return Choir.empty(); + return Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM"); } McpTransportContext transportContext = this.contextExtractor.extract(request); if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - response.entity(Entity.createText(response, "Session ID required in mcp-session-id header")); - return Choir.empty(); + return Entity.createText(response, "Session ID required in mcp-session-id header"); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -202,13 +201,13 @@ public Choir handleGet(HttpClassicServerRequest request, HttpClassicS if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Choir.empty(); + return Entity.createObject(response, new McpError("Session not found: " + sessionId)); } logger.debug("Handling GET request for session: {}", sessionId); try { - return Choir.create(emitter -> { + return Choir.create(emitter -> { // TODO onTimeout() // emitter.onTimeout(() -> { // logger.debug("SSE connection timed out for session: {}", sessionId); @@ -270,7 +269,7 @@ public void onFailed(Exception cause) { catch (Exception e) { logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Choir.empty(); + return null; } } @@ -279,31 +278,24 @@ public void onFailed(Exception cause) { * @param request The incoming server request containing the JSON-RPC message */ @PostMapping(path = MESSAGE_ENDPOINT) - public Choir handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response) { - String body = response.entity().toString(); - System.out.println("111111111111111111: "+ body); + public Object handlePost(HttpClassicServerRequest request, + HttpClassicServerResponse response, + @RequestBody Map requestBody) { if (this.isClosing) { - System.out.println("2"); response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - response.entity(Entity.createText(response, "Server is shutting down")); - return Choir.empty(); + return Entity.createText(response, "Server is shutting down"); } - List acceptHeaders = request.headers().all(MessageHeaderNames.ACCEPT); + String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value()) || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { - System.out.println("3"); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - response.entity(Entity.createObject(response, new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON"))); - return Choir.empty(); + return Entity.createObject(response, new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON")); } - McpTransportContext transportContext = this.contextExtractor.extract(request); - System.out.println("4"); try { - System.out.println("5"); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(objectMapper, body); + McpSchema.JSONRPCMessage message = this.deserializeJsonRpcMessage(requestBody); // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest @@ -320,23 +312,21 @@ public Choir handlePost(HttpClassicServerRequest request, HttpClassic response.statusCode(HttpResponseStatus.OK.statusCode()); response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); - response.entity(Entity.createObject(response, - new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null))); - return Choir.empty(); + return Entity.createObject(response, + new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); } catch (Exception e) { + System.out.println("33"); logger.error("Failed to initialize session: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - response.entity(Entity.createObject(response, new McpError(e.getMessage()))); - return Choir.empty(); + return Entity.createObject(response, new McpError(e.getMessage())); } } // Handle other messages that require a session if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - response.entity(Entity.createObject(response, new McpError("Session ID missing"))); - return Choir.empty(); + return Entity.createObject(response, new McpError("Session ID missing")); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -344,8 +334,7 @@ public Choir handlePost(HttpClassicServerRequest request, HttpClassic if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - response.entity(Entity.createObject(response, new McpError("Session not found: " + sessionId))); - return Choir.empty(); + return Entity.createObject(response, new McpError("Session not found: " + sessionId)); } if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { @@ -353,7 +342,7 @@ public Choir handlePost(HttpClassicServerRequest request, HttpClassic .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); - return Choir.empty(); + return null; } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { @@ -361,11 +350,11 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); - return Choir.empty(); + return null; } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { // For streaming responses, we need to return SSE - return Choir.create(emitter -> { + return Choir.create(emitter -> { // TODO onComplete() and onTimeout() // emitter.onComplete(() -> { // logger.debug("Request response stream completed for session: {}", sessionId); @@ -388,21 +377,18 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - response.entity(Entity.createObject(response, new McpError("Unknown message type"))); - return Choir.empty(); + return Entity.createObject(response, new McpError("Unknown message type")); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - response.entity(Entity.createObject(response, new McpError("Invalid message format"))); - return Choir.empty(); + return Entity.createObject(response, new McpError("Invalid message format")); } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - response.entity(Entity.createObject(response, new McpError(e.getMessage()))); - return Choir.empty(); + return Entity.createObject(response, new McpError(e.getMessage())); } } @@ -411,24 +397,22 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { * @param request The incoming server request */ @DeleteMapping(path = MESSAGE_ENDPOINT) - public void handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { + public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - response.entity(Entity.createText(response, "Server is shutting down")); - return; + return Entity.createText(response, "Server is shutting down"); } if (this.disallowDelete) { response.statusCode(HttpResponseStatus.METHOD_NOT_ALLOWED.statusCode()); - return; + return null; } McpTransportContext transportContext = this.contextExtractor.extract(request); if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - response.entity(Entity.createText(response, "Session ID required in mcp-session-id header")); - return; + return Entity.createText(response, "Session ID required in mcp-session-id header"); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -436,19 +420,37 @@ public void handleDelete(HttpClassicServerRequest request, HttpClassicServerResp if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return; + return Entity.createObject(response, new McpError("Session not found: " + sessionId)); } try { session.delete().contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); this.sessions.remove(sessionId); response.statusCode(HttpResponseStatus.OK.statusCode()); + return null; } catch (Exception e) { logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - response.entity(Entity.createObject(response, new McpError(e.getMessage()))); + return Entity.createObject(response, new McpError(e.getMessage())); + } + } + + public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) + throws IOException { + + // Determine message type based on specific JSON structure + if (map.containsKey("method") && map.containsKey("id")) { + return objectMapper.convertValue(map, McpSchema.JSONRPCRequest.class); + } + else if (map.containsKey("method") && !map.containsKey("id")) { + return objectMapper.convertValue(map, McpSchema.JSONRPCNotification.class); } + else if (map.containsKey("result") || map.containsKey("error")) { + return objectMapper.convertValue(map, McpSchema.JSONRPCResponse.class); + } + + throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + map.toString()); } /** From a97b99a984eea379bb26f63867b661e0ef208ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 16 Oct 2025 11:19:17 +0800 Subject: [PATCH 07/37] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tMcpStreamableServerTransportProvider.java | 127 ++++++++++++------ .../DefaultStreamableSyncMcpServer.java | 17 ++- .../fitframework/flowable/Emitter.java | 2 + 3 files changed, 104 insertions(+), 42 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index 807ac94a4..f77eac820 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -8,8 +8,6 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; -import modelengine.fel.tool.mcp.entity.JsonRpc; -import modelengine.fel.tool.mcp.server.McpServerController; import modelengine.fit.http.entity.TextEvent; import modelengine.fit.http.annotation.*; import modelengine.fit.http.entity.Entity; @@ -21,7 +19,6 @@ import modelengine.fitframework.flowable.Choir; import modelengine.fitframework.flowable.Emitter; import modelengine.fitframework.log.Logger; -import modelengine.fitframework.serialization.ObjectSerializer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -32,9 +29,18 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +/** + * The default implementation of {@link McpStreamableServerTransportProvider}. + * The FIT transport provider for MCP Server, + * according to {@code WebMvcStreamableServerTransportProvider} in MCP SDK. + * + * + * @author 黄可欣 + * @since 2025-10-15 + */ public class DefaultMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { - private static final Logger logger = Logger.get(McpServerController.class); + private static final Logger logger = Logger.get(DefaultMcpStreamableServerTransportProvider.class); private static final String MESSAGE_ENDPOINT = "/mcp/streamable"; @@ -78,13 +84,15 @@ public class DefaultMcpStreamableServerTransportProvider implements McpStreamabl /** * Constructs a new DefaultMcpStreamableServerTransportProvider instance. + * * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization * of messages. * @param disallowDelete Whether to disallow DELETE requests on the endpoint. * @throws IllegalArgumentException if any parameter is null */ private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, - boolean disallowDelete, McpTransportContextExtractor contextExtractor, + boolean disallowDelete, + McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { Assert.notNull(objectMapper, "ObjectMapper must not be null"); Assert.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); @@ -118,6 +126,7 @@ public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) * Broadcasts a notification to all connected clients through their SSE connections. * If any errors occur during sending to a particular client, they are logged but * don't prevent sending to other clients. + * * @param method The method name for the notification * @param params The parameters for the notification * @return A Mono that completes when the broadcast attempt is finished @@ -129,7 +138,7 @@ public Mono notifyClients(String method, Object params) { return Mono.empty(); } - logger.debug("Attempting to broadcast message to {} active sessions", this.sessions.size()); + logger.info("Attempting to broadcast message to {} active sessions", this.sessions.size()); return Mono.fromRunnable(() -> { this.sessions.values().parallelStream().forEach(session -> { @@ -145,13 +154,14 @@ public Mono notifyClients(String method, Object params) { /** * Initiates a graceful shutdown of the transport. + * * @return A Mono that completes when all cleanup operations are finished */ @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { this.isClosing = true; - logger.debug("Initiating graceful shutdown with {} active sessions", this.sessions.size()); + logger.info("Initiating graceful shutdown with {} active sessions", this.sessions.size()); this.sessions.values().parallelStream().forEach(session -> { try { @@ -163,7 +173,7 @@ public Mono closeGracefully() { }); this.sessions.clear(); - logger.debug("Graceful shutdown completed"); + logger.info("Graceful shutdown completed"); }).then().doOnSuccess(v -> { if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); @@ -174,7 +184,10 @@ public Mono closeGracefully() { /** * Set up the listening SSE connections and message replay. + * * @param request The incoming server request + * @param response The HTTP response + * @return Return the HTTP response body {@link Entity} or a {@link Choir}{@code <}{@link TextEvent}{@code >} object */ @GetMapping(path = MESSAGE_ENDPOINT) public Object handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { @@ -204,22 +217,20 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo return Entity.createObject(response, new McpError("Session not found: " + sessionId)); } - logger.debug("Handling GET request for session: {}", sessionId); + logger.info("[GET] Handling GET request for session: {}", sessionId); try { return Choir.create(emitter -> { - // TODO onTimeout() -// emitter.onTimeout(() -> { -// logger.debug("SSE connection timed out for session: {}", sessionId); -// }); + // TODO emitter.onTimeout() logger.info() DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( sessionId, emitter); // Check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { - String lastId = request.headers().first(HttpHeaders.LAST_EVENT_ID).orElse(""); + String lastId = request.headers().first(HttpHeaders.LAST_EVENT_ID).orElse("0"); + logger.info("[GET] Receiving replay request from session: {}", sessionId); try { session.replay(lastId) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) @@ -243,24 +254,25 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo } else { // Establish new listening stream + logger.info("[GET] Receiving Get request to establish new SSE for session: {}", sessionId); McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session .listeningStream(sessionTransport); emitter.observe(new Emitter.Observer() { @Override public void onEmittedData(TextEvent data) { - // No action needed on emitted data + // No action needed } @Override public void onCompleted() { - logger.debug("SSE connection completed for session: {}", sessionId); + logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); listeningStream.close(); } @Override public void onFailed(Exception cause) { - // Close the listening stream on failure + // No action needed } }); } @@ -275,7 +287,11 @@ public void onFailed(Exception cause) { /** * Handles POST requests for incoming JSON-RPC messages from clients. + * * @param request The incoming server request containing the JSON-RPC message + * @param response The HTTP response + * @param requestBody the map of JSON-RPC message + * @return Return the HTTP response body {@link Entity} or a {@link Choir}{@code <}{@link TextEvent}{@code >} object */ @PostMapping(path = MESSAGE_ENDPOINT) public Object handlePost(HttpClassicServerRequest request, @@ -290,7 +306,8 @@ public Object handlePost(HttpClassicServerRequest request, if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value()) || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON")); + return Entity.createObject(response, + new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON")); } McpTransportContext transportContext = this.contextExtractor.extract(request); try { @@ -300,6 +317,7 @@ public Object handlePost(HttpClassicServerRequest request, // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { + logger.info("[POST] Handling initialize method, with receiving message: {}", requestBody.toString()); McpSchema.InitializeRequest initializeRequest = objectMapper.convertValue(jsonrpcRequest.params(), new TypeReference() { }); @@ -312,11 +330,15 @@ public Object handlePost(HttpClassicServerRequest request, response.statusCode(HttpResponseStatus.OK.statusCode()); response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); + logger.info( + "[POST] Sending message via HTTP response to session {}: {}", + init.session().getId(), + requestBody.toString() + ); return Entity.createObject(response, new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); } catch (Exception e) { - System.out.println("33"); logger.error("Failed to initialize session: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, new McpError(e.getMessage())); @@ -331,6 +353,7 @@ public Object handlePost(HttpClassicServerRequest request, String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); McpStreamableServerSession session = this.sessions.get(sessionId); + logger.info("[POST] Receiving message from session {}: {}", sessionId, requestBody.toString()); if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); @@ -343,7 +366,6 @@ public Object handlePost(HttpClassicServerRequest request, .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); return null; - } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { session.accept(jsonrpcNotification) @@ -355,13 +377,23 @@ else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { // For streaming responses, we need to return SSE return Choir.create(emitter -> { - // TODO onComplete() and onTimeout() -// emitter.onComplete(() -> { -// logger.debug("Request response stream completed for session: {}", sessionId); -// }); -// emitter.onTimeout(() -> { -// logger.debug("Request response stream timed out for session: {}", sessionId); -// }); + // TODO emitter.onTimeout() logger.info + emitter.observe(new Emitter.Observer() { + @Override + public void onEmittedData(TextEvent data) { + // No action needed + } + + @Override + public void onCompleted() { + logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); + } + + @Override + public void onFailed(Exception e) { + // No action needed + } + }); DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport(sessionId, emitter); @@ -369,7 +401,6 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { session.responseStream(jsonrpcRequest, sessionTransport) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); - logger.debug("Request response stream completed for session: {}", sessionId); } catch (Exception e) { logger.error("Failed to handle request stream: {}", e.getMessage()); emitter.fail(e); @@ -394,7 +425,10 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { /** * Handles DELETE requests for session deletion. + * * @param request The incoming server request + * @param response The HTTP response + * @return Return HTTP response body {@link Entity}. */ @DeleteMapping(path = MESSAGE_ENDPOINT) public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { @@ -418,6 +452,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); McpStreamableServerSession session = this.sessions.get(sessionId); + logger.info("[DELETE] Receiving delete request from session: {}", sessionId); if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); return Entity.createObject(response, new McpError("Session not found: " + sessionId)); @@ -436,6 +471,13 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe } } + /** + * Handles DELETE requests for session deletion. + * + * @param map the map of JSON-RPC message + * @return The corresponding {@link McpSchema.JSONRPCMessage} class + * @throws IOException when cannot deserialize JSONRPCMessage + */ public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) throws IOException { @@ -474,16 +516,18 @@ private class DefaultStreamableMcpSessionTransport implements McpStreamableServe /** * Creates a new session transport with the specified ID and SSE builder. + * * @param sessionId The unique identifier for this session */ DefaultStreamableMcpSessionTransport(String sessionId, Emitter emitter) { this.sessionId = sessionId; this.emitter = emitter; - logger.debug("Streamable session transport {} initialized with SSE builder", sessionId); + logger.info("[SSE] Building SSE for session: {} ", sessionId); } /** * Sends a JSON-RPC message to the client through the SSE connection. + * * @param message The JSON-RPC message to send * @return A Mono that completes when the message has been sent */ @@ -495,6 +539,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { /** * Sends a JSON-RPC message to the client through the SSE connection with a * specific message ID. + * * @param message The JSON-RPC message to send * @param messageId The message ID for SSE event identification * @return A Mono that completes when the message has been sent @@ -503,14 +548,14 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId) { return Mono.fromRunnable(() -> { if (this.closed) { - logger.debug("Attempted to send message to closed session: {}", this.sessionId); + logger.info("Attempted to send message to closed session: {}", this.sessionId); return; } this.lock.lock(); try { if (this.closed) { - logger.debug("Session {} was closed during message send attempt", this.sessionId); + logger.info("Session {} was closed during message send attempt", this.sessionId); return; } @@ -518,7 +563,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId TextEvent textEvent = TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); - logger.debug("Message sent to session {} with ID {}", this.sessionId, messageId); + logger.info("[SSE] Sending message to session {}: {}", this.sessionId, jsonText); } catch (Exception e) { logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); @@ -538,6 +583,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId /** * Converts data from one type to another using the configured ObjectMapper. + * * @param data The source data object to convert * @param typeRef The target type reference * @return The converted object of type T @@ -550,13 +596,12 @@ public T unmarshalFrom(Object data, TypeReference typeRef) { /** * Initiates a graceful shutdown of the transport. + * * @return A Mono that completes when the shutdown is complete */ @Override public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - DefaultStreamableMcpSessionTransport.this.close(); - }); + return Mono.fromRunnable(DefaultStreamableMcpSessionTransport.this::close); } /** @@ -567,14 +612,14 @@ public void close() { this.lock.lock(); try { if (this.closed) { - logger.debug("Session transport {} already closed", this.sessionId); + logger.info("Session transport {} already closed", this.sessionId); return; } this.closed = true; this.emitter.complete(); - logger.debug("Successfully completed SSE builder for session {}", sessionId); + logger.info("[SSE] Closed SSE builder successfully for session {}", sessionId); } catch (Exception e) { logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage()); @@ -605,8 +650,8 @@ public static class Builder { private Duration keepAliveInterval; /** - * Sets the ObjectMapper to use for JSON serialization/deserialization of MCP - * messages. + * 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 @@ -619,6 +664,7 @@ public Builder objectMapper(ObjectMapper objectMapper) { /** * Sets whether to disallow DELETE requests on the endpoint. + * * @param disallowDelete true to disallow DELETE requests, false otherwise * @return this builder instance */ @@ -632,6 +678,7 @@ public Builder disallowDelete(boolean disallowDelete) { * 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 @@ -646,6 +693,7 @@ public Builder contextExtractor(McpTransportContextExtractor { * @param cause 表示失败原因的 {@link Exception}。 */ void onFailed(Exception cause); + + // TODO onTimeout()方法 } } From 35b55a4d23a3828bf269496cf120e92c2cc2879b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Fri, 17 Oct 2025 09:46:56 +0800 Subject: [PATCH 08/37] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMcpStreamableServerTransportProvider.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index f77eac820..d2aeebd9b 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -31,9 +31,7 @@ /** * The default implementation of {@link McpStreamableServerTransportProvider}. - * The FIT transport provider for MCP Server, - * according to {@code WebMvcStreamableServerTransportProvider} in MCP SDK. - * + * The FIT transport provider for MCP Server, according to {@code WebMvcStreamableServerTransportProvider} in MCP SDK. * * @author 黄可欣 * @since 2025-10-15 @@ -83,11 +81,14 @@ public class DefaultMcpStreamableServerTransportProvider implements McpStreamabl private KeepAliveScheduler keepAliveScheduler; /** - * Constructs a new DefaultMcpStreamableServerTransportProvider instance. + * Constructs a new DefaultMcpStreamableServerTransportProvider instance, + * for {@link DefaultMcpStreamableServerTransportProvider.Builder}. * * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization * of messages. * @param disallowDelete Whether to disallow DELETE requests on the endpoint. + * @param contextExtractor The context extractor to fill in a {@link McpTransportContext}. + * @param keepAliveInterval The interval for sending keep-alive messages to clients. * @throws IllegalArgumentException if any parameter is null */ private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, @@ -560,7 +561,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId } String jsonText = objectMapper.writeValueAsString(message); - TextEvent textEvent = TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); + TextEvent textEvent = TextEvent.custom() + .id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); logger.info("[SSE] Sending message to session {}: {}", this.sessionId, jsonText); From 35f7e68965459e84f1be7ea55dd32627053b48d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Fri, 17 Oct 2025 10:47:34 +0800 Subject: [PATCH 09/37] =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/DefaultMcpStreamableServerTransportProvider.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index d2aeebd9b..5c38f3670 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -332,9 +332,8 @@ public Object handlePost(HttpClassicServerRequest request, response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); logger.info( - "[POST] Sending message via HTTP response to session {}: {}", - init.session().getId(), - requestBody.toString() + "[POST] Sending initialize message via HTTP response to session {}", + init.session().getId() ); return Entity.createObject(response, new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); From 25125a132151a8ccabf6d1e9c88bc7314248d028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Fri, 17 Oct 2025 14:25:32 +0800 Subject: [PATCH 10/37] =?UTF-8?q?=E6=9B=B4=E6=96=B0MCP=20SDK=E7=89=88?= =?UTF-8?q?=E6=9C=AC0.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/java/plugins/tool-mcp-server/pom.xml | 2 +- ...tMcpStreamableServerTransportProvider.java | 45 +++++++++++++------ 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/pom.xml b/framework/fel/java/plugins/tool-mcp-server/pom.xml index 306ff8c3b..0d2a4ff70 100644 --- a/framework/fel/java/plugins/tool-mcp-server/pom.xml +++ b/framework/fel/java/plugins/tool-mcp-server/pom.xml @@ -44,7 +44,7 @@ io.modelcontextprotocol.sdk mcp - 0.12.0 + 0.14.0 diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index 5c38f3670..233216b18 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.*; import io.modelcontextprotocol.util.Assert; @@ -23,6 +24,7 @@ import reactor.core.publisher.Mono; import java.io.IOException; +import java.lang.reflect.Type; import java.time.Duration; import java.util.List; import java.util.Map; @@ -115,7 +117,7 @@ private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, @Override public List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26); + return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); } @Override @@ -215,7 +217,9 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, new McpError("Session not found: " + sessionId)); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: " + sessionId) + .build()); } logger.info("[GET] Handling GET request for session: {}", sessionId); @@ -308,7 +312,9 @@ public Object handlePost(HttpClassicServerRequest request, || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return Entity.createObject(response, - new McpError("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON")); + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON") + .build()); } McpTransportContext transportContext = this.contextExtractor.extract(request); try { @@ -341,14 +347,18 @@ public Object handlePost(HttpClassicServerRequest request, catch (Exception e) { logger.error("Failed to initialize session: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, new McpError(e.getMessage())); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) + .message(e.getMessage()) + .build()); } } // Handle other messages that require a session if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, new McpError("Session ID missing")); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Session ID missing") + .build()); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -357,7 +367,9 @@ public Object handlePost(HttpClassicServerRequest request, if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, new McpError("Session not found: " + sessionId)); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: " + sessionId) + .build()); } if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { @@ -408,18 +420,18 @@ public void onFailed(Exception e) { } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, new McpError("Unknown message type")); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, new McpError("Invalid message format")); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format").build()); } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, new McpError(e.getMessage())); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); } } @@ -455,7 +467,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe logger.info("[DELETE] Receiving delete request from session: {}", sessionId); if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, new McpError("Session not found: " + sessionId)); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS).message("Session not found: " + sessionId).build()); } try { @@ -467,7 +479,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe catch (Exception e) { logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, new McpError(e.getMessage())); + return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); } } @@ -591,8 +603,15 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId * @param The target type */ @Override - public T unmarshalFrom(Object data, TypeReference typeRef) { - return objectMapper.convertValue(data, typeRef); + public T unmarshalFrom(Object data, TypeRef typeRef) { + // Convert TypeRef to TypeReference for ObjectMapper compatibility + TypeReference typeReference = new TypeReference() { + @Override + public Type getType() { + return typeRef.getType(); + } + }; + return objectMapper.convertValue(data, typeReference); } /** From 8f0b7e7e97c0687238b19525dee0f2e19cf7bbf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Fri, 17 Oct 2025 14:52:20 +0800 Subject: [PATCH 11/37] =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMcpStreamableServerTransportProvider.java | 8 +++++++- .../server/support/DefaultStreamableSyncMcpServer.java | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java index 233216b18..578838034 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java @@ -1,3 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + package modelengine.fel.tool.mcp.server.support; import com.fasterxml.jackson.core.type.TypeReference; @@ -36,7 +42,7 @@ * The FIT transport provider for MCP Server, according to {@code WebMvcStreamableServerTransportProvider} in MCP SDK. * * @author 黄可欣 - * @since 2025-10-15 + * @since 2025-09-30 */ public class DefaultMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java index aa1314c5e..fff45f5be 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java @@ -1,3 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + package modelengine.fel.tool.mcp.server.support; import com.fasterxml.jackson.databind.ObjectMapper; @@ -13,7 +19,7 @@ * Mcp Server implemented with MCP SDK. * * @author 黄可欣 - * @since 2025-10-15 + * @since 2025-09-30 */ @Component public class DefaultStreamableSyncMcpServer { From 3773db9e7c5d6e96c74bdc0d24813f7ae31a07e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 22 Oct 2025 09:43:40 +0800 Subject: [PATCH 12/37] =?UTF-8?q?fit=E5=B7=A5=E5=85=B7=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tMcpStreamableServerTransportProvider.java | 2 +- .../DefaultStreamableSyncMcpServer.java | 172 +++++++++++++ .../fel/tool/mcp/server/McpServer.java | 22 +- .../tool/mcp/server/McpServerController.java | 182 -------------- .../fel/tool/mcp/server/MessageHandler.java | 28 --- .../fel/tool/mcp/server/MessageRequest.java | 17 -- .../fel/tool/mcp/server/MessageResponse.java | 17 -- .../handler/AbstractMessageHandler.java | 43 ---- .../mcp/server/handler/InitializeHandler.java | 49 ---- .../handler/LoggingSetLevelHandler.java | 75 ------ .../tool/mcp/server/handler/PingHandler.java | 38 --- .../mcp/server/handler/ToolCallHandler.java | 232 ------------------ .../mcp/server/handler/ToolListHandler.java | 52 ---- .../handler/UnsupportedMethodHandler.java | 45 ---- .../mcp/server/support/DefaultMcpServer.java | 112 --------- .../DefaultStreamableSyncMcpServer.java | 55 ----- .../mcp/server/McpServerControllerTest.java | 56 ----- ...> DefaultStreamableSyncMcpServerTest.java} | 51 ++-- .../fel/java/services/tool-mcp-common/pom.xml | 7 + .../fel/tool/mcp/entity/ServerSchema.java | 36 +-- .../modelengine/fel/tool/mcp/entity/Tool.java | 13 +- 21 files changed, 230 insertions(+), 1074 deletions(-) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{support => }/DefaultMcpStreamableServerTransportProvider.java (99%) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerController.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageRequest.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageResponse.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/AbstractMessageHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/InitializeHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/LoggingSetLevelHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/PingHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolCallHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolListHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/UnsupportedMethodHandler.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/McpServerControllerTest.java rename framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/{DefaultMcpServerTest.java => DefaultStreamableSyncMcpServerTest.java} (75%) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java similarity index 99% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java index 578838034..eabb85a28 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -package modelengine.fel.tool.mcp.server.support; +package modelengine.fel.tool.mcp.server; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java new file mode 100644 index 000000000..43cb11cb5 --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.tool.mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.mcp.entity.ServerSchema; +import modelengine.fel.tool.mcp.entity.Tool; +import modelengine.fel.tool.service.ToolChangedObserver; +import modelengine.fel.tool.service.ToolExecuteService; +import modelengine.fitframework.annotation.Component; +import io.modelcontextprotocol.server.McpSyncServer; +import modelengine.fitframework.log.Logger; +import modelengine.fitframework.util.MapUtils; +import modelengine.fitframework.util.StringUtils; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; + +import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; +import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; +import static modelengine.fel.tool.info.schema.ToolsSchema.REQUIRED; +import static modelengine.fitframework.inspection.Validation.notNull; + +/** + * Mcp Server implemented with MCP SDK. + * + * @author 黄可欣 + * @since 2025-09-30 + */ +@Component +public class DefaultStreamableSyncMcpServer implements McpServer, ToolChangedObserver { + private static final Logger log = Logger.get(DefaultStreamableSyncMcpServer.class); + private final McpSyncServer mcpSyncServer; + + private final Map tools = new ConcurrentHashMap<>(); + private final ToolExecuteService toolExecuteService; + private final List toolsChangedObservers = new ArrayList<>(); + + /** + * Constructs a new instance of the DefaultMcpServer class. + * + * @param toolExecuteService The service used to execute tools when handling tool call requests. + * @throws IllegalArgumentException If {@code toolExecuteService} is null. + */ + public DefaultStreamableSyncMcpServer(ToolExecuteService toolExecuteService) { + DefaultMcpStreamableServerTransportProvider transportProvider = DefaultMcpStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) + .build(); + this.mcpSyncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider) + .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, true) // Enable resource support + .tools(true) // Enable tool support + .prompts(true) // Enable prompt support + .logging() // Enable logging support + .completions() // Enable completions support + .build()) + .requestTimeout(Duration.ofSeconds(10)) + .build(); + this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); + } + + @Override + public ServerSchema getSchema() { + McpSchema.Implementation info = this.mcpSyncServer.getServerInfo(); + McpSchema.ServerCapabilities capabilities= this.mcpSyncServer.getServerCapabilities(); + return new ServerSchema("2025-06-18", capabilities, info); + } + + @Override + public List getTools() { + return List.copyOf(this.tools.values()); + } + + @Override + public void registerToolsChangedObserver(ToolsChangedObserver observer) { + if (observer != null) { + this.toolsChangedObservers.add(observer); + } + } + + @Override + public void addTool(String name, String description, McpSchema.JsonSchema inputSchema, + BiFunction callHandler) { + if (StringUtils.isBlank(name)) { + log.warn("Tool addition is ignored: tool name is blank."); + return; + } + if (StringUtils.isBlank(description)) { + log.warn("Tool addition is ignored: tool description is blank. [toolName={}]", name); + return; + } + if (inputSchema == null) { + log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); + return; + } + if (callHandler == null) { + log.warn("Tool addition is ignored: tool call handler is null or empty. [toolName={}]", name); + return; + } + + McpServerFeatures.SyncToolSpecification toolSpecification = McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name(name) + .description(description) + .inputSchema(inputSchema) + .build()) + .callHandler(callHandler) + .build(); + this.mcpSyncServer.addTool(toolSpecification); + + Tool tool = new Tool(); + tool.setName(name); + tool.setDescription(description); + tool.setInputSchema(inputSchema); + this.tools.put(name, tool); + log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, inputSchema); + this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); + } + + @Override + public void removeTool(String name) { + if (StringUtils.isBlank(name)) { + log.warn("Tool removal is ignored: tool name is blank."); + return; + } + this.mcpSyncServer.removeTool(name); + this.tools.remove(name); + log.info("Tool removed from MCP server. [toolName={}]", name); + this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); + } + + @Override + public void onToolAdded(String name, String description, Map parameters) { + if (MapUtils.isEmpty(parameters)) { + log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); + return; + } + if (!(parameters.get(TYPE) instanceof String) + || !(parameters.get(PROPERTIES) instanceof Map) + || !(parameters.get(REQUIRED) instanceof List)) { + + log.warn("Invalid parameter schema. [toolName={}]", name); + return; + } + @SuppressWarnings("unchecked") + McpSchema.JsonSchema hkxSchema = new McpSchema.JsonSchema((String) parameters.get(TYPE), + (Map) parameters.get(PROPERTIES), (List) parameters.get(REQUIRED), + null, null,null); + this.addTool(name, description, hkxSchema, (exchange, request) -> { + Map args = request.arguments(); + String result = this.toolExecuteService.execute(name, args); + return new McpSchema.CallToolResult(result, true); + }); + } + + @Override + public void onToolRemoved(String name) { + this.removeTool(name); + } +} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java index d62c13b8b..6134726c0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java @@ -6,11 +6,14 @@ package modelengine.fel.tool.mcp.server; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; /** * Represents the MCP Server. @@ -34,13 +37,22 @@ public interface McpServer { List getTools(); /** - * Calls MCP server tool. + * Add a tool. * - * @param name The tool name as a {@link String}. - * @param arguments The tool arguments as a {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. - * @return The tool result as a {@link Object}. + * @param name The name of the added tool, as a {@link String}. + * @param description A description of the added tool, as a {@link String}. + * @param inputSchema The parameters associated with the added tool, as a {@link McpSchema.JsonSchema}. + * @param callHandler The tool call handler as a {@link BiFunction} */ - Object callTool(String name, Map arguments); + void addTool(String name, String description, McpSchema.JsonSchema inputSchema, + BiFunction callHandler); + + /** + * Remove a tool. + * + * @param name The name of the removed tool, as a {@link String}. + */ + void removeTool(String name); /** * Registers MCP server tools changed observer. diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerController.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerController.java deleted file mode 100644 index 3585020db..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerController.java +++ /dev/null @@ -1,182 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server; - -import static modelengine.fitframework.inspection.Validation.notNull; -import static modelengine.fitframework.util.ObjectUtils.cast; - -import modelengine.fel.tool.mcp.entity.Event; -import modelengine.fel.tool.mcp.entity.JsonRpc; -import modelengine.fel.tool.mcp.entity.Method; -import modelengine.fel.tool.mcp.server.handler.InitializeHandler; -import modelengine.fel.tool.mcp.server.handler.PingHandler; -import modelengine.fel.tool.mcp.server.handler.ToolCallHandler; -import modelengine.fel.tool.mcp.server.handler.ToolListHandler; -import modelengine.fel.tool.mcp.server.handler.LoggingSetLevelHandler; -import modelengine.fel.tool.mcp.server.handler.UnsupportedMethodHandler; -import modelengine.fit.http.annotation.GetMapping; -import modelengine.fit.http.annotation.PostMapping; -import modelengine.fit.http.annotation.RequestBody; -import modelengine.fit.http.annotation.RequestQuery; -import modelengine.fit.http.entity.TextEvent; -import modelengine.fit.http.server.HttpClassicServerResponse; -import modelengine.fitframework.annotation.Component; -import modelengine.fitframework.annotation.Fit; -import modelengine.fitframework.flowable.Choir; -import modelengine.fitframework.flowable.Emitter; -import modelengine.fitframework.log.Logger; -import modelengine.fitframework.schedule.ExecutePolicy; -import modelengine.fitframework.schedule.Task; -import modelengine.fitframework.schedule.ThreadPoolScheduler; -import modelengine.fitframework.serialization.ObjectSerializer; -import modelengine.fitframework.util.CollectionUtils; -import modelengine.fitframework.util.MapUtils; -import modelengine.fitframework.util.StringUtils; -import modelengine.fitframework.util.UuidUtils; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * FIT MCP Server controller. - * - * @author 季聿阶 - * @since 2025-05-13 - */ -@Component -public class McpServerController implements McpServer.ToolsChangedObserver { - private static final Logger log = Logger.get(McpServerController.class); - private static final String MESSAGE_PATH = "/mcp/message"; - private static final String RESPONSE_OK = StringUtils.EMPTY; - - private final Map> emitters = new ConcurrentHashMap<>(); - private final Map responses = new ConcurrentHashMap<>(); - private final Map methodHandlers = new HashMap<>(); - private final MessageHandler unsupportedMethodHandler = new UnsupportedMethodHandler(); - private final ObjectSerializer serializer; - - /** - * Constructs a new instance of the McpController class. - * - * @param serializer The JSON serializer used to serialize and deserialize RPC messages, as an - * {@link ObjectSerializer}. - * @param mcpServer The MCP server instance used to handle tool operations such as initialization, - * listing tools, and calling tools, as a {@link McpServer}. - */ - public McpServerController(@Fit(alias = "json") ObjectSerializer serializer, McpServer mcpServer) { - this.serializer = notNull(serializer, "The json serializer cannot be null."); - notNull(mcpServer, "The MCP server cannot be null."); - mcpServer.registerToolsChangedObserver(this); - - this.methodHandlers.put(Method.INITIALIZE.code(), new InitializeHandler(mcpServer)); - this.methodHandlers.put(Method.PING.code(), new PingHandler()); - this.methodHandlers.put(Method.TOOLS_LIST.code(), new ToolListHandler(mcpServer)); - this.methodHandlers.put(Method.TOOLS_CALL.code(), new ToolCallHandler(mcpServer, this.serializer)); - this.methodHandlers.put(Method.LOGGING_SET_LEVEL.code(), new LoggingSetLevelHandler()); - - ThreadPoolScheduler channelDetectorScheduler = ThreadPoolScheduler.custom() - .corePoolSize(1) - .isDaemonThread(true) - .threadPoolName("mcp-server-channel-detector") - .build(); - channelDetectorScheduler.schedule(Task.builder().policy(ExecutePolicy.fixedDelay(10000)).runnable(() -> { - if (MapUtils.isEmpty(this.responses)) { - return; - } - List obsoleteSessionIds = new ArrayList<>(); - for (Map.Entry entry : this.responses.entrySet()) { - if (entry.getValue().isActive()) { - continue; - } - obsoleteSessionIds.add(entry.getKey()); - } - if (CollectionUtils.isEmpty(obsoleteSessionIds)) { - return; - } - obsoleteSessionIds.forEach(this.responses::remove); - for (String obsoleteSessionId : obsoleteSessionIds) { - Emitter removed = this.emitters.remove(obsoleteSessionId); - removed.complete(); - } - log.info("Channels are inactive, remove emitters and responses. [sessionIds={}]", obsoleteSessionIds); - }).build()); - } - - /** - * Creates a Server-Sent Events (SSE) channel for real-time communication with the client. - * - *

This method generates a unique session ID and registers an emitter to send events.

- * - * @param response The HTTP server response object used to manage the SSE connection as a - * {@link HttpClassicServerResponse}. - * @return A {@link Choir}{@code <}{@link TextEvent}{@code >} object that emits text events to the connected client. - */ - @GetMapping(path = "/sse") - public Choir createSse(HttpClassicServerResponse response) { - String sessionId = UuidUtils.randomUuidString(); - this.responses.put(sessionId, response); - log.info("New SSE channel for MCP server created. [sessionId={}]", sessionId); - return Choir.create(emitter -> { - emitters.put(sessionId, emitter); - String data = MESSAGE_PATH + "?session_id=" + sessionId; - TextEvent textEvent = TextEvent.custom().id(sessionId).event(Event.ENDPOINT.code()).data(data).build(); - emitter.emit(textEvent); - log.info("Send MCP endpoint. [endpoint={}]", data); - }); - } - - /** - * Receives and processes an MCP message via HTTP POST request. - * - *

This method handles incoming JSON-RPC requests, routes them to the appropriate handler, - * and returns a response via the associated event emitter.

- * - * @param sessionId The session ID used to identify the current client session. - * @param request The JSON-RPC request entity containing the method name and parameters. - * @return Always returns an empty string ({@value #RESPONSE_OK}) to indicate success. - */ - @PostMapping(path = MESSAGE_PATH) - public Object receiveMcpMessage(@RequestQuery(name = "session_id") String sessionId, - @RequestBody Map request) { - log.info("Receive MCP message. [sessionId={}, message={}]", sessionId, request); - Object id = request.get("id"); - if (id == null) { - // Request without an ID indicates a notification message, ignore. - return RESPONSE_OK; - } - String method = cast(request.getOrDefault("method", StringUtils.EMPTY)); - MessageHandler handler = this.methodHandlers.getOrDefault(method, this.unsupportedMethodHandler); - JsonRpc.Response response; - try { - Object result = handler.handle(cast(request.get("params"))); - response = JsonRpc.createResponse(id, result); - } catch (Exception e) { - log.error("Failed to handle MCP message.", e); - response = JsonRpc.createResponseWithError(id, e.getMessage()); - } - String serialized = this.serializer.serialize(response); - TextEvent textEvent = TextEvent.custom().id(sessionId).event(Event.MESSAGE.code()).data(serialized).build(); - Emitter emitter = this.emitters.get(sessionId); - emitter.emit(textEvent); - log.info("Send MCP message. [message={}]", serialized); - return RESPONSE_OK; - } - - @Override - public void onToolsChanged() { - JsonRpc.Notification notification = JsonRpc.createNotification(Method.NOTIFICATION_TOOLS_CHANGED.code()); - String serialized = this.serializer.serialize(notification); - this.emitters.forEach((sessionId, emitter) -> { - TextEvent textEvent = TextEvent.custom().id(sessionId).event(Event.MESSAGE.code()).data(serialized).build(); - emitter.emit(textEvent); - log.info("Send MCP notification: tools changed. [sessionId={}]", sessionId); - }); - } -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageHandler.java deleted file mode 100644 index 458a17ef6..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageHandler.java +++ /dev/null @@ -1,28 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server; - -import java.util.Map; - -/** - * A functional interface for handling messages in the MCP server. - * Implementations of this interface are responsible for processing incoming message requests - * and returning an appropriate response object. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public interface MessageHandler { - /** - * Handles the given message request. - * - * @param request A map containing the request parameters and data as a - * {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. - * @return The result of processing the request as an {@link Object}, which can be any type of object. - */ - Object handle(Map request); -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageRequest.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageRequest.java deleted file mode 100644 index b850f672c..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageRequest.java +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server; - -/** - * A base class for all message request types in the MCP server. - * This class serves as a common ancestor for specific message request classes, - * providing a shared structure and type for message handling in the system. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public class MessageRequest {} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageResponse.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageResponse.java deleted file mode 100644 index c32634c0a..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/MessageResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server; - -/** - * A base class for all message response types in the MCP server. - * This class serves as a common ancestor for specific message response classes, - * providing a shared structure and type for returning results after message processing. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public class MessageResponse {} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/AbstractMessageHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/AbstractMessageHandler.java deleted file mode 100644 index 770489c11..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/AbstractMessageHandler.java +++ /dev/null @@ -1,43 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import static modelengine.fitframework.inspection.Validation.notNull; - -import modelengine.fel.tool.mcp.server.MessageHandler; -import modelengine.fel.tool.mcp.server.MessageRequest; -import modelengine.fitframework.util.ObjectUtils; - -import java.util.Map; - -/** - * The abstract parent class of {@link MessageHandler}. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public abstract class AbstractMessageHandler implements MessageHandler { - private final Class requestClass; - - AbstractMessageHandler(Class requestClass) { - this.requestClass = notNull(requestClass, "The request class cannot be null."); - } - - @Override - public Object handle(Map request) { - Req req = ObjectUtils.toCustomObject(request, this.requestClass); - return this.handle(req); - } - - /** - * Handles the request. - * - * @param request The request as a {@link Req}. - * @return The response as a {@link Object}. - */ - abstract Object handle(Req request); -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/InitializeHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/InitializeHandler.java deleted file mode 100644 index 8cd9ecd8f..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/InitializeHandler.java +++ /dev/null @@ -1,49 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import static modelengine.fitframework.inspection.Validation.notNull; - -import modelengine.fel.tool.mcp.server.McpServer; -import modelengine.fel.tool.mcp.server.MessageRequest; - -/** - * A handler for processing initialization requests in the MCP server. - * This class extends {@link AbstractMessageHandler} and is responsible for handling - * {@link InitializeRequest} messages by retrieving server information via the associated {@link McpServer}. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public class InitializeHandler extends AbstractMessageHandler { - private final McpServer mcpServer; - - /** - * Constructs a new instance of the InitializeHandler class. - * - * @param mcpServer The MCP server instance used to retrieve server information during request handling. - * @throws IllegalArgumentException If {@code mcpServer} is null. - */ - public InitializeHandler(McpServer mcpServer) { - super(InitializeRequest.class); - this.mcpServer = notNull(mcpServer, "The MCP server cannot be null."); - } - - @Override - protected Object handle(InitializeRequest request) { - return this.mcpServer.getSchema(); - } - - /** - * Represents an initialization request in the MCP server. - * This request is handled by {@link InitializeHandler} to retrieve server information. - * - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class InitializeRequest extends MessageRequest {} -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/LoggingSetLevelHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/LoggingSetLevelHandler.java deleted file mode 100644 index 1cc2fb440..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/LoggingSetLevelHandler.java +++ /dev/null @@ -1,75 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import modelengine.fel.tool.mcp.entity.LoggingLevel; -import modelengine.fel.tool.mcp.server.MessageRequest; -import modelengine.fitframework.util.StringUtils; - -import java.util.Collections; -import java.util.Map; - -/** - * A handler for processing logging set level requests in the MCP server. - * This class extends {@link AbstractMessageHandler} and is responsible for handling - * {@link LoggingSetLevelRequest} messages. - * - * @author 黄可欣 - * @since 2025-09-10 - */ -public class LoggingSetLevelHandler extends AbstractMessageHandler { - private static final Map SET_LEVEL_RESULT = Collections.emptyMap(); - - /** - * Constructs a new instance of the LoggingSetLevelHandler class. - */ - public LoggingSetLevelHandler() { - super(LoggingSetLevelHandler.LoggingSetLevelRequest.class); - } - - @Override - public Object handle(LoggingSetLevelHandler.LoggingSetLevelRequest request) { - if (request == null) { - throw new IllegalStateException("No logging set level request."); - } - if (StringUtils.isBlank(request.getLevel())) { - throw new IllegalStateException("No logging level in request."); - } - String loggingLevelString = request.getLevel(); - LoggingLevel loggingLevel = LoggingLevel.fromCode(loggingLevelString); - // TODO change the logging level of corresponding session. - return SET_LEVEL_RESULT; - } - - /** - * Represents a request to set the logging level in the MCP server. - * This request is handled by {@link LoggingSetLevelHandler} to set the logging level in the MCP server. - * - * @since 2025-09-10 - */ - public static class LoggingSetLevelRequest extends MessageRequest { - private String level; - - /** - * Gets the level of server logging. - * - * @return The level of server logging as a {@link String}. - */ - public String getLevel() { - return this.level; - } - - /** - * Sets the level of server logging . - * - * @param level The level of server logging as a {@link String}. - */ - public void setLevel(String level) { - this.level = level; - } - } -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/PingHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/PingHandler.java deleted file mode 100644 index 5e0f1fd2e..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/PingHandler.java +++ /dev/null @@ -1,38 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import modelengine.fel.tool.mcp.server.MessageRequest; - -import java.util.Collections; -import java.util.Map; - -/** - * @author 季聿阶 - * @since 2025-05-15 - */ -public class PingHandler extends AbstractMessageHandler { - private static final Map PING_RESULT = Collections.emptyMap(); - - /** - * Constructs a new instance of the PingHandler class. - */ - public PingHandler() { - super(PingRequest.class); - } - - @Override - public Object handle(PingRequest request) { - return PING_RESULT; - } - - /** - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class PingRequest extends MessageRequest {} -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolCallHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolCallHandler.java deleted file mode 100644 index 291f6e70b..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolCallHandler.java +++ /dev/null @@ -1,232 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import static modelengine.fitframework.inspection.Validation.notNull; -import static modelengine.fitframework.util.ObjectUtils.cast; - -import modelengine.fel.tool.mcp.server.McpServer; -import modelengine.fel.tool.mcp.server.MessageRequest; -import modelengine.fel.tool.mcp.server.MessageResponse; -import modelengine.fitframework.annotation.Property; -import modelengine.fitframework.serialization.ObjectSerializer; -import modelengine.fitframework.util.StringUtils; - -import java.util.List; -import java.util.Map; - -/** - * A handler for processing tool call requests in the MCP server. - * This class extends {@link AbstractMessageHandler} and is responsible for handling - * {@link ToolCallRequest} messages by invoking the specified tool via the associated {@link McpServer}. - * It serializes the result using the provided {@link ObjectSerializer} and returns a structured - * response through the {@link ToolCallResponse} class. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public class ToolCallHandler extends AbstractMessageHandler { - private final McpServer mcpServer; - private final ObjectSerializer jsonSerializer; - - /** - * Constructs a new instance of the ToolCallHandler class. - * - * @param mcpServer The MCP server instance used to invoke tools during request handling. - * @param jsonSerializer The serializer used to convert non-string results into JSON strings. - * @throws IllegalArgumentException If {@code mcpServer} or {@code jsonSerializer} is null. - */ - public ToolCallHandler(McpServer mcpServer, ObjectSerializer jsonSerializer) { - super(ToolCallRequest.class); - this.mcpServer = notNull(mcpServer, "The MCP server cannot be null."); - this.jsonSerializer = notNull(jsonSerializer, "The json serializer cannot be null."); - } - - @Override - protected Object handle(ToolCallRequest request) { - if (request == null) { - throw new IllegalStateException("No tool call request."); - } - if (StringUtils.isBlank(request.getName())) { - throw new IllegalStateException("No tool name to call."); - } - ToolCallResponse response = new ToolCallResponse(); - ToolCallResponse.Content content = new ToolCallResponse.Content(); - response.setContents(List.of(content)); - content.setType("text"); - try { - Object result = this.mcpServer.callTool(request.getName(), request.getArguments()); - if (result instanceof String) { - content.setText(cast(result)); - } else { - content.setText(this.jsonSerializer.serialize(result)); - } - response.setError(false); - } catch (Exception e) { - content.setText(e.getMessage()); - response.setError(true); - } - return response; - } - - /** - * Represents a tool call request in the MCP server. - * This request contains the name of the tool to be invoked and a map of arguments - * to be passed to the tool. It is handled by {@link ToolCallHandler} to execute the tool - * and return the result. - * - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class ToolCallRequest extends MessageRequest { - private String name; - private Map arguments; - - /** - * Gets the name of the tool to be called. - * - * @return The name of the tool as a {@link String}. - */ - public String getName() { - return this.name; - } - - /** - * Sets the name of the tool to be called. - * - * @param name The name of the tool as a {@link String}. - */ - public void setName(String name) { - this.name = name; - } - - /** - * Gets the arguments to be passed to the tool. - * - * @return A map containing the arguments as a {@link Map}{@code <}{@link String}{@code , - * }{@link Object}{@code >}. - */ - public Map getArguments() { - return this.arguments; - } - - /** - * Sets the arguments to be passed to the tool. - * - * @param arguments A map containing the arguments as a - * {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. - */ - public void setArguments(Map arguments) { - this.arguments = arguments; - } - } - - /** - * Represents the structured response returned after executing a tool call. - * This class includes a list of content items and an error flag indicating - * whether the execution was successful. - * - *

Each content item has a type and text value, which can be used to represent - * the result or error message from the tool execution.

- * - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class ToolCallResponse extends MessageResponse { - @Property(name = "content") - private List contents; - private boolean isError; - - /** - * Gets the list of content items included in the response. - * - * @return A list of content items as a {@link List}{@code <}{@link Content}{@code >}. - */ - public List getContents() { - return this.contents; - } - - /** - * Sets the list of content items included in the response. - * - * @param contents A list of content items as a {@link List}{@code <}{@link Content}{@code >}. - */ - public void setContents(List contents) { - this.contents = contents; - } - - /** - * Checks whether the tool execution resulted in an error. - * - * @return true if an error occurred; false otherwise. - */ - public boolean isError() { - return this.isError; - } - - /** - * Sets the error flag indicating whether the tool execution resulted in an error. - * - * @param error true if an error occurred; false otherwise. - */ - public void setError(boolean error) { - this.isError = error; - } - - /** - * Represents a single content item within the tool call response. - * Each content item has a type (e.g., "text", "json") and a text value, - * typically used to describe the result or error message from the tool execution. - * - *

This class supports multiple content formats, allowing flexible representation - * of the tool's output.

- * - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class Content { - private String type; - private String text; - - /** - * Gets the type of the content item. - * - * @return The type of the content as a {@link String}. - */ - public String getType() { - return this.type; - } - - /** - * Sets the type of the content item. - * - * @param type The type of the content as a {@link String}. - */ - public void setType(String type) { - this.type = type; - } - - /** - * Gets the text value of the content item. - * - * @return The text value as a {@link String}. - */ - public String getText() { - return this.text; - } - - /** - * Sets the text value of the content item. - * - * @param text The text value as a {@link String}. - */ - public void setText(String text) { - this.text = text; - } - } - } -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolListHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolListHandler.java deleted file mode 100644 index be8ac2760..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolListHandler.java +++ /dev/null @@ -1,52 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import static modelengine.fitframework.inspection.Validation.notNull; - -import modelengine.fel.tool.mcp.server.McpServer; -import modelengine.fel.tool.mcp.server.MessageRequest; -import modelengine.fitframework.util.MapBuilder; - -/** - * A handler for processing tool list requests in the MCP server. - * This class extends {@link AbstractMessageHandler} and is responsible for handling - * {@link ToolListRequest} messages by retrieving the list of tools from the associated {@link McpServer} - * and returning them in a structured map format. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public class ToolListHandler extends AbstractMessageHandler { - private final McpServer mcpServer; - - /** - * Constructs a new instance of the ToolListHandler class. - * - * @param mcpServer The MCP server instance used to retrieve the list of tools during request handling. - * @throws IllegalArgumentException If {@code mcpServer} is null. - */ - public ToolListHandler(McpServer mcpServer) { - super(ToolListRequest.class); - this.mcpServer = notNull(mcpServer, "The MCP server cannot be null."); - } - - @Override - public Object handle(ToolListRequest request) { - return MapBuilder.get().put("tools", this.mcpServer.getTools()).build(); - } - - /** - * Represents a tool list request in the MCP server. - * This request is handled by {@link ToolListHandler} to retrieve the list of available tools - * from the server and return them in a structured format. - * - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class ToolListRequest extends MessageRequest {} -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/UnsupportedMethodHandler.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/UnsupportedMethodHandler.java deleted file mode 100644 index a83dd2c04..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/UnsupportedMethodHandler.java +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.handler; - -import modelengine.fel.tool.mcp.server.MessageRequest; -import modelengine.fel.tool.mcp.server.MessageResponse; - -/** - * Represents a request for an unsupported method in the MCP server. - * This request is handled by {@link UnsupportedMethodHandler} to indicate that the - * corresponding operation is not implemented or supported. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public class UnsupportedMethodHandler - extends AbstractMessageHandler { - /** - * Constructs a new instance of the UnsupportedMethodHandler class. - * - *

This handler is used to handle requests for methods that are not supported or implemented.

- */ - public UnsupportedMethodHandler() { - super(UnsupportedMethodRequest.class); - } - - @Override - public MessageResponse handle(UnsupportedMethodRequest request) { - throw new UnsupportedOperationException("Not supported request method."); - } - - /** - * Represents a request for an operation that is not supported by the current handler. - * This class is used in conjunction with {@link UnsupportedMethodHandler} to signal - * that the requested method has no implementation. - * - * @author 季聿阶 - * @since 2025-05-15 - */ - public static class UnsupportedMethodRequest extends MessageRequest {} -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java deleted file mode 100644 index 4287ecda7..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java +++ /dev/null @@ -1,112 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.support; - -import static modelengine.fitframework.inspection.Validation.notNull; - -import modelengine.fel.tool.mcp.entity.ServerSchema; -import modelengine.fel.tool.mcp.entity.Tool; -import modelengine.fel.tool.mcp.server.McpServer; -import modelengine.fel.tool.service.ToolChangedObserver; -import modelengine.fel.tool.service.ToolExecuteService; -import modelengine.fitframework.annotation.Component; -import modelengine.fitframework.log.Logger; -import modelengine.fitframework.util.MapUtils; -import modelengine.fitframework.util.StringUtils; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * The default implementation of {@link McpServer}. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -@Component -public class DefaultMcpServer implements McpServer, ToolChangedObserver { - private static final Logger log = Logger.get(DefaultMcpServer.class); - - private final ToolExecuteService toolExecuteService; - private final Map tools = new ConcurrentHashMap<>(); - private final List toolsChangedObservers = new ArrayList<>(); - - /** - * Constructs a new instance of the DefaultMcpServer class. - * - * @param toolExecuteService The service used to execute tools when handling tool call requests. - * @throws IllegalArgumentException If {@code toolExecuteService} is null. - */ - public DefaultMcpServer(ToolExecuteService toolExecuteService) { - this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); - } - - @Override - public ServerSchema getSchema() { - ServerSchema.Info info = new ServerSchema.Info("FIT Store MCP Server", "3.6.0-SNAPSHOT"); - ServerSchema.Capabilities.Logging logging = new ServerSchema.Capabilities.Logging(); - ServerSchema.Capabilities.Tools tools = new ServerSchema.Capabilities.Tools(true); - ServerSchema.Capabilities capabilities = new ServerSchema.Capabilities(logging, tools); - return new ServerSchema("2024-11-05", capabilities, info); - } - - @Override - public List getTools() { - return List.copyOf(this.tools.values()); - } - - @Override - public Object callTool(String name, Map arguments) { - log.info("Calling tool. [toolName={}, arguments={}]", name, arguments); - String result = this.toolExecuteService.execute(name, arguments); - log.info("Tool called. [result={}]", result); - return result; - } - - @Override - public void registerToolsChangedObserver(ToolsChangedObserver observer) { - if (observer != null) { - this.toolsChangedObservers.add(observer); - } - } - - @Override - public void onToolAdded(String name, String description, Map parameters) { - if (StringUtils.isBlank(name)) { - log.warn("Tool addition is ignored: tool name is blank."); - return; - } - if (StringUtils.isBlank(description)) { - log.warn("Tool addition is ignored: tool description is blank. [toolName={}]", name); - return; - } - if (MapUtils.isEmpty(parameters)) { - log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); - return; - } - Tool tool = new Tool(); - tool.setName(name); - tool.setDescription(description); - tool.setInputSchema(parameters); - this.tools.put(name, tool); - log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); - this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); - } - - @Override - public void onToolRemoved(String name) { - if (StringUtils.isBlank(name)) { - log.warn("Tool removal is ignored: tool name is blank."); - return; - } - this.tools.remove(name); - log.info("Tool removed from MCP server. [toolName={}]", name); - this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); - } -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java deleted file mode 100644 index fff45f5be..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServer.java +++ /dev/null @@ -1,55 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server.support; - -import com.fasterxml.jackson.databind.ObjectMapper; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fitframework.annotation.Bean; -import modelengine.fitframework.annotation.Component; -import io.modelcontextprotocol.server.McpSyncServer; - -import java.time.Duration; - -/** - * Mcp Server implemented with MCP SDK. - * - * @author 黄可欣 - * @since 2025-09-30 - */ -@Component -public class DefaultStreamableSyncMcpServer { - /** - * Construct a transport provider Bean for MCP Server. - */ - @Bean - public DefaultMcpStreamableServerTransportProvider defaultMcpStreamableServerTransportProvider() { - return DefaultMcpStreamableServerTransportProvider.builder() - .objectMapper(new ObjectMapper()) - .build(); - } - - /** - * Construct a synchronized MCP Server Bean with MCP SDK. - * - * @param transportProvider The bean of {@link DefaultMcpStreamableServerTransportProvider}. - */ - @Bean - public McpSyncServer mcpSyncServer(DefaultMcpStreamableServerTransportProvider transportProvider) { - return McpServer.sync(transportProvider) - .serverInfo("fit-mcp-streamable-server", "1.0.0") - .capabilities(McpSchema.ServerCapabilities.builder() - .resources(false, true) // Enable resource support - .tools(true) // Enable tool support - .prompts(true) // Enable prompt support - .logging() // Enable logging support - .completions() // Enable completions support - .build()) - .requestTimeout(Duration.ofSeconds(10)) - .build(); - } -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/McpServerControllerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/McpServerControllerTest.java deleted file mode 100644 index 068e46718..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/McpServerControllerTest.java +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. - * This file is a part of the ModelEngine Project. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -package modelengine.fel.tool.mcp.server; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowableOfType; -import static org.mockito.Mockito.mock; - -import modelengine.fitframework.serialization.ObjectSerializer; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -/** - * Unit test for {@link McpServerController}. - * - * @author 季聿阶 - * @since 2025-05-20 - */ -@DisplayName("Unit tests for McpController") -public class McpServerControllerTest { - private ObjectSerializer objectSerializer; - private McpServer mcpServer; - - @BeforeEach - void setup() { - this.objectSerializer = mock(ObjectSerializer.class); - this.mcpServer = mock(McpServer.class); - } - - @Nested - @DisplayName("Constructor Tests") - class GivenConstructor { - @Test - @DisplayName("Should throw exception when serializer is null") - void shouldThrowExceptionWhenSerializerIsNull() { - var exception = catchThrowableOfType(IllegalArgumentException.class, - () -> new McpServerController(null, mcpServer)); - assertThat(exception).hasMessage("The json serializer cannot be null."); - } - - @Test - @DisplayName("Should throw exception when mcpServer is null") - void shouldThrowExceptionWhenMcpServerIsNull() { - var exception = catchThrowableOfType(IllegalArgumentException.class, - () -> new McpServerController(objectSerializer, null)); - assertThat(exception).hasMessage("The MCP server cannot be null."); - } - } -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServerTest.java similarity index 75% rename from framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java rename to framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServerTest.java index 555592161..3572736f0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServerTest.java @@ -16,8 +16,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; +import modelengine.fel.tool.mcp.server.DefaultStreamableSyncMcpServer; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; @@ -31,13 +33,13 @@ import java.util.Map; /** - * Unit test for {@link DefaultMcpServer}. + * Unit test for {@link DefaultStreamableSyncMcpServer}. * * @author 季聿阶 * @since 2025-05-20 */ -@DisplayName("Unit tests for DefaultMcpServer") -public class DefaultMcpServerTest { +@DisplayName("Unit tests for DefaultStreamableSyncMcpServer") +public class DefaultStreamableSyncMcpServerTest { private ToolExecuteService toolExecuteService; @BeforeEach @@ -52,7 +54,7 @@ class GivenConstructor { @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { IllegalArgumentException exception = - catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpServer(null)); + catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultStreamableSyncMcpServer(null)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -63,20 +65,17 @@ class GivenGetInfo { @Test @DisplayName("Should return expected server information") void returnExpectedServerInfo() { - McpServer server = new DefaultMcpServer(toolExecuteService); + McpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); ServerSchema info = server.getSchema(); - assertThat(info).returns("2024-11-05", ServerSchema::protocolVersion); + assertThat(info).returns("2025-06-18", ServerSchema::protocolVersion); - ServerSchema.Capabilities capabilities = info.capabilities(); + McpSchema.ServerCapabilities capabilities = info.capabilities(); assertThat(capabilities).isNotNull(); - ServerSchema.Capabilities.Tools toolsCapability = capabilities.tools(); - assertThat(toolsCapability).returns(true, ServerSchema.Capabilities.Tools::listChanged); - - ServerSchema.Info serverInfo = info.serverInfo(); - assertThat(serverInfo).returns("FIT Store MCP Server", ServerSchema.Info::name) - .returns("3.6.0-SNAPSHOT", ServerSchema.Info::version); + McpSchema.Implementation serverInfo = info.serverInfo(); + assertThat(serverInfo).returns("FIT Store MCP Server", McpSchema.Implementation::name) + .returns("3.6.0-SNAPSHOT", McpSchema.Implementation::version); } } @@ -86,7 +85,7 @@ class GivenRegisterAndNotify { @Test @DisplayName("Should notify observers when tools are added or removed") void notifyObserversOnToolAddOrRemove() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); @@ -106,7 +105,7 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get().put("input", "value").build(); @@ -125,7 +124,7 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); server.onToolAdded("", "description", MapBuilder.get().put("input", "value").build()); assertThat(server.getTools()).isEmpty(); @@ -144,7 +143,7 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); server.onToolAdded("tool1", "desc", MapBuilder.get().put("input", "value").build()); server.onToolRemoved("tool1"); @@ -155,7 +154,7 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); server.onToolAdded("tool1", "desc", MapBuilder.get().put("input", "value").build()); server.onToolRemoved(""); @@ -163,20 +162,4 @@ void ignoreBlankName() { assertThat(server.getTools()).hasSize(1); } } - - @Nested - @DisplayName("callTool Method Tests") - class GivenCallTool { - @Test - @DisplayName("Should call the tool and return correct result") - void callToolSuccessfully() { - when(toolExecuteService.execute(anyString(), anyMap())).thenReturn("result"); - McpServer server = new DefaultMcpServer(toolExecuteService); - - Object result = server.callTool("tool1", Map.of("arg1", "value1")); - - assertThat(result).isEqualTo("result"); - verify(toolExecuteService, times(1)).execute(eq("tool1"), anyMap()); - } - } } \ No newline at end of file diff --git a/framework/fel/java/services/tool-mcp-common/pom.xml b/framework/fel/java/services/tool-mcp-common/pom.xml index d18215525..8823d6964 100644 --- a/framework/fel/java/services/tool-mcp-common/pom.xml +++ b/framework/fel/java/services/tool-mcp-common/pom.xml @@ -37,5 +37,12 @@ org.assertj assertj-core + + + + io.modelcontextprotocol.sdk + mcp + 0.14.0 + \ No newline at end of file diff --git a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java index 7125c5602..b641de68e 100644 --- a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java +++ b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java @@ -6,10 +6,12 @@ package modelengine.fel.tool.mcp.entity; -import static modelengine.fitframework.util.ObjectUtils.cast; +import io.modelcontextprotocol.spec.McpSchema; import java.util.Map; +import static modelengine.fitframework.util.ObjectUtils.cast; + /** * Represents a server entity in the MCP framework, encapsulating information about the server's protocol version, * capabilities, and additional server details. @@ -17,7 +19,7 @@ * @author 季聿阶 * @since 2025-05-22 */ -public record ServerSchema(String protocolVersion, Capabilities capabilities, Info serverInfo) { +public record ServerSchema(String protocolVersion, McpSchema.ServerCapabilities capabilities, McpSchema.Implementation serverInfo) { /** * Creates a new {@link ServerSchema} instance based on the provided map of server information. * @@ -27,35 +29,17 @@ public record ServerSchema(String protocolVersion, Capabilities capabilities, In public static ServerSchema create(Map map) { String protocolVersion = cast(map.get("protocolVersion")); Map capabilitiesMap = cast(map.get("capabilities")); - Capabilities.Logging logging = new Capabilities.Logging(); Map toolsMap = cast(capabilitiesMap.get("tools")); boolean toolsListChanged = cast(toolsMap.getOrDefault("listChanged", false)); - Capabilities.Tools tools = new Capabilities.Tools(toolsListChanged); - Capabilities capabilities = new Capabilities(logging, tools); + McpSchema.ServerCapabilities capabilities = McpSchema.ServerCapabilities.builder() + .tools(toolsListChanged) // Enable tool support + .logging() // Enable logging support + .completions() // Enable completions support + .build(); Map infoMap = cast(map.get("serverInfo")); String name = cast(infoMap.get("name")); String version = cast(infoMap.get("version")); - Info serverInfo = new Info(name, version); + McpSchema.Implementation serverInfo = new McpSchema.Implementation(name, version); return new ServerSchema(protocolVersion, capabilities, serverInfo); } - - /** - * Represents the capabilities supported by the server, including logging and tool-related functionalities. - */ - public record Capabilities(Logging logging, Tools tools) { - /** - * Represents the logging capabilities of the server. - */ - public record Logging() {} - - /** - * Represents the tool-related capabilities of the server, including whether the tool list has changed. - */ - public record Tools(boolean listChanged) {} - } - - /** - * Represents additional information about the server, such as its name and version. - */ - public record Info(String name, String version) {} } \ No newline at end of file diff --git a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java index 0b7c0f692..a712c5ed4 100644 --- a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java +++ b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java @@ -6,7 +6,7 @@ package modelengine.fel.tool.mcp.entity; -import java.util.Map; +import io.modelcontextprotocol.spec.McpSchema; /** * Represents a tool entity with name, description, and schema. @@ -31,7 +31,7 @@ public class Tool { * The input schema that defines the expected parameters when invoking the tool. * Typically represented using a map structure, e.g., JSON schema format. */ - private Map inputSchema; + private McpSchema.JsonSchema inputSchema; /** * Gets the name of the tool. @@ -73,19 +73,18 @@ public void setDescription(String description) { * Gets the input schema of the tool. * This defines the required and optional parameters for invoking the tool. * - * @return The tool's input schema as a {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. + * @return The tool's input schema as a {@link McpSchema.JsonSchema}. */ - public Map getInputSchema() { + public McpSchema.JsonSchema getInputSchema() { return this.inputSchema; } /** * Sets the input schema of the tool. * - * @param inputSchema The new input schema for the tool, as a - * {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. + * @param inputSchema The new input schema for the tool, as a {@link McpSchema.JsonSchema}. */ - public void setInputSchema(Map inputSchema) { + public void setInputSchema(McpSchema.JsonSchema inputSchema) { this.inputSchema = inputSchema; } } From cc56e29fafafd3a343fc3bdd2c67e46ddc300541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 22 Oct 2025 16:21:27 +0800 Subject: [PATCH 13/37] DefaultMcpServer --- ...ncMcpServer.java => DefaultMcpServer.java} | 6 ++--- ...verTest.java => DefaultMcpServerTest.java} | 24 ++++++++----------- 2 files changed, 13 insertions(+), 17 deletions(-) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{DefaultStreamableSyncMcpServer.java => DefaultMcpServer.java} (96%) rename framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/{DefaultStreamableSyncMcpServerTest.java => DefaultMcpServerTest.java} (84%) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java similarity index 96% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 43cb11cb5..276abe2a6 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultStreamableSyncMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -39,8 +39,8 @@ * @since 2025-09-30 */ @Component -public class DefaultStreamableSyncMcpServer implements McpServer, ToolChangedObserver { - private static final Logger log = Logger.get(DefaultStreamableSyncMcpServer.class); +public class DefaultMcpServer implements McpServer, ToolChangedObserver { + private static final Logger log = Logger.get(DefaultMcpServer.class); private final McpSyncServer mcpSyncServer; private final Map tools = new ConcurrentHashMap<>(); @@ -53,7 +53,7 @@ public class DefaultStreamableSyncMcpServer implements McpServer, ToolChangedObs * @param toolExecuteService The service used to execute tools when handling tool call requests. * @throws IllegalArgumentException If {@code toolExecuteService} is null. */ - public DefaultStreamableSyncMcpServer(ToolExecuteService toolExecuteService) { + public DefaultMcpServer(ToolExecuteService toolExecuteService) { DefaultMcpStreamableServerTransportProvider transportProvider = DefaultMcpStreamableServerTransportProvider.builder() .objectMapper(new ObjectMapper()) .build(); diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java similarity index 84% rename from framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServerTest.java rename to framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index 3572736f0..34704ffd4 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultStreamableSyncMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -8,18 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; -import static org.mockito.Mockito.anyMap; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; -import modelengine.fel.tool.mcp.server.DefaultStreamableSyncMcpServer; +import modelengine.fel.tool.mcp.server.DefaultMcpServer; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; @@ -33,13 +29,13 @@ import java.util.Map; /** - * Unit test for {@link DefaultStreamableSyncMcpServer}. + * Unit test for {@link DefaultMcpServer}. * * @author 季聿阶 * @since 2025-05-20 */ @DisplayName("Unit tests for DefaultStreamableSyncMcpServer") -public class DefaultStreamableSyncMcpServerTest { +public class DefaultMcpServerTest { private ToolExecuteService toolExecuteService; @BeforeEach @@ -54,7 +50,7 @@ class GivenConstructor { @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { IllegalArgumentException exception = - catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultStreamableSyncMcpServer(null)); + catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpServer(null)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -65,7 +61,7 @@ class GivenGetInfo { @Test @DisplayName("Should return expected server information") void returnExpectedServerInfo() { - McpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); + McpServer server = new DefaultMcpServer(toolExecuteService); ServerSchema info = server.getSchema(); assertThat(info).returns("2025-06-18", ServerSchema::protocolVersion); @@ -85,7 +81,7 @@ class GivenRegisterAndNotify { @Test @DisplayName("Should notify observers when tools are added or removed") void notifyObserversOnToolAddOrRemove() { - DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); @@ -105,7 +101,7 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get().put("input", "value").build(); @@ -124,7 +120,7 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); server.onToolAdded("", "description", MapBuilder.get().put("input", "value").build()); assertThat(server.getTools()).isEmpty(); @@ -143,7 +139,7 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); server.onToolAdded("tool1", "desc", MapBuilder.get().put("input", "value").build()); server.onToolRemoved("tool1"); @@ -154,7 +150,7 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultStreamableSyncMcpServer server = new DefaultStreamableSyncMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); server.onToolAdded("tool1", "desc", MapBuilder.get().put("input", "value").build()); server.onToolRemoved(""); From 784366111d2dfec7dcae4a6f69d0fbe6cb14b57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 22 Oct 2025 16:23:25 +0800 Subject: [PATCH 14/37] =?UTF-8?q?DefaultMcpServerTest=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/support/DefaultMcpServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index 34704ffd4..aa3ca34a9 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -34,7 +34,7 @@ * @author 季聿阶 * @since 2025-05-20 */ -@DisplayName("Unit tests for DefaultStreamableSyncMcpServer") +@DisplayName("Unit tests for DefaultMcpServer") public class DefaultMcpServerTest { private ToolExecuteService toolExecuteService; From 8ae3896b779896ea028ef51dd4220fc8ff2621a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 22 Oct 2025 16:49:08 +0800 Subject: [PATCH 15/37] =?UTF-8?q?DefaultMcpServerTest=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/DefaultMcpServer.java | 2 +- .../server/support/DefaultMcpServerTest.java | 40 ++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 276abe2a6..838b42277 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -161,7 +161,7 @@ public void onToolAdded(String name, String description, Map par this.addTool(name, description, hkxSchema, (exchange, request) -> { Map args = request.arguments(); String result = this.toolExecuteService.execute(name, args); - return new McpSchema.CallToolResult(result, true); + return new McpSchema.CallToolResult(result, false); }); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index aa3ca34a9..c32d45631 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -85,9 +86,12 @@ void notifyObserversOnToolAddOrRemove() { McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); - server.onToolAdded("tool1", - "description1", - MapBuilder.get().put("schema", "value1").build()); + Map schema = MapBuilder.get() + .put("type", "object") + .put("properties", Collections.emptyMap()) + .put("required", Collections.emptyList()) + .build(); + server.onToolAdded("tool1", "description1", schema); verify(observer, times(1)).onToolsChanged(); server.onToolRemoved("tool1"); @@ -104,7 +108,11 @@ void addToolSuccessfully() { DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); String name = "tool1"; String description = "description1"; - Map schema = MapBuilder.get().put("input", "value").build(); + Map schema = MapBuilder.get() + .put("type", "object") + .put("properties", Collections.emptyMap()) + .put("required", Collections.emptyList()) + .build(); server.onToolAdded(name, description, schema); @@ -114,18 +122,22 @@ void addToolSuccessfully() { Tool tool = tools.get(0); assertThat(tool.getName()).isEqualTo(name); assertThat(tool.getDescription()).isEqualTo(description); - assertThat(tool.getInputSchema()).isEqualTo(schema); } @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + Map schema = MapBuilder.get() + .put("type", "object") + .put("properties", Collections.emptyMap()) + .put("required", Collections.emptyList()) + .build(); - server.onToolAdded("", "description", MapBuilder.get().put("input", "value").build()); + server.onToolAdded("", "description", schema); assertThat(server.getTools()).isEmpty(); - server.onToolAdded("tool1", "", MapBuilder.get().put("input", "value").build()); + server.onToolAdded("tool1", "", schema); assertThat(server.getTools()).isEmpty(); server.onToolAdded("tool1", "description", null); @@ -140,7 +152,12 @@ class GivenOnToolRemoved { @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); - server.onToolAdded("tool1", "desc", MapBuilder.get().put("input", "value").build()); + Map schema = MapBuilder.get() + .put("type", "object") + .put("properties", Collections.emptyMap()) + .put("required", Collections.emptyList()) + .build(); + server.onToolAdded("tool1", "desc", schema); server.onToolRemoved("tool1"); @@ -151,7 +168,12 @@ void removeToolSuccessfully() { @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); - server.onToolAdded("tool1", "desc", MapBuilder.get().put("input", "value").build()); + Map schema = MapBuilder.get() + .put("type", "object") + .put("properties", Collections.emptyMap()) + .put("required", Collections.emptyList()) + .build(); + server.onToolAdded("tool1", "desc", schema); server.onToolRemoved(""); From 4a65f2dec3a883945a0959018372c5d2f4f62b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 22 Oct 2025 17:05:29 +0800 Subject: [PATCH 16/37] =?UTF-8?q?input=20schema=E5=88=A4=E6=96=AD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modelengine/fel/tool/mcp/server/DefaultMcpServer.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 838b42277..0cf880672 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -148,9 +148,8 @@ public void onToolAdded(String name, String description, Map par return; } if (!(parameters.get(TYPE) instanceof String) - || !(parameters.get(PROPERTIES) instanceof Map) - || !(parameters.get(REQUIRED) instanceof List)) { - + || parameters.get(PROPERTIES) != null && !(parameters.get(PROPERTIES) instanceof Map) + || parameters.get(REQUIRED) != null && !(parameters.get(REQUIRED) instanceof List)) { log.warn("Invalid parameter schema. [toolName={}]", name); return; } From 65e0a0fbf9fea513b93fc5f750f71ddbbfc3df59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 22 Oct 2025 17:40:36 +0800 Subject: [PATCH 17/37] =?UTF-8?q?=E6=B7=BB=E5=8A=A0Server=20Bean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/DefaultMcpServer.java | 20 ++------ .../tool/mcp/server/DefaultMcpServerBean.java | 46 +++++++++++++++++++ .../server/support/DefaultMcpServerTest.java | 24 ++++------ 3 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 0cf880672..1a32400f9 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -14,6 +14,7 @@ import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.service.ToolChangedObserver; import modelengine.fel.tool.service.ToolExecuteService; +import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fitframework.log.Logger; @@ -33,7 +34,7 @@ import static modelengine.fitframework.inspection.Validation.notNull; /** - * Mcp Server implemented with MCP SDK. + * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} with MCP SDK. * * @author 黄可欣 * @since 2025-09-30 @@ -53,22 +54,9 @@ public class DefaultMcpServer implements McpServer, ToolChangedObserver { * @param toolExecuteService The service used to execute tools when handling tool call requests. * @throws IllegalArgumentException If {@code toolExecuteService} is null. */ - public DefaultMcpServer(ToolExecuteService toolExecuteService) { - DefaultMcpStreamableServerTransportProvider transportProvider = DefaultMcpStreamableServerTransportProvider.builder() - .objectMapper(new ObjectMapper()) - .build(); - this.mcpSyncServer = io.modelcontextprotocol.server.McpServer.sync(transportProvider) - .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") - .capabilities(McpSchema.ServerCapabilities.builder() - .resources(false, true) // Enable resource support - .tools(true) // Enable tool support - .prompts(true) // Enable prompt support - .logging() // Enable logging support - .completions() // Enable completions support - .build()) - .requestTimeout(Duration.ofSeconds(10)) - .build(); + public DefaultMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); + this.mcpSyncServer = mcpSyncServer; } @Override diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java new file mode 100644 index 000000000..5c6c9cf7a --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved. + * This file is a part of the ModelEngine Project. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +package modelengine.fel.tool.mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fitframework.annotation.Bean; +import modelengine.fitframework.annotation.Component; + +import java.time.Duration; + +/** + * Mcp Server Bean implemented with MCP SDK. + * + * @author 黄可欣 + * @since 2025-10-22 + */ +@Component +public class DefaultMcpServerBean { + @Bean + public DefaultMcpStreamableServerTransportProvider defaultMcpStreamableServerTransportProvider() { + return DefaultMcpStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) + .build(); + } + + @Bean + public McpSyncServer mcpSyncServer(DefaultMcpStreamableServerTransportProvider transportProvider) { + return io.modelcontextprotocol.server.McpServer.sync(transportProvider) + .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") + .capabilities(McpSchema.ServerCapabilities.builder() + .resources(false, true) // Enable resource support + .tools(true) // Enable tool support + .prompts(true) // Enable prompt support + .logging() // Enable logging support + .completions() // Enable completions support + .build()) + .requestTimeout(Duration.ofSeconds(10)) + .build(); + } +} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index c32d45631..fb069a86d 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; @@ -38,10 +39,12 @@ @DisplayName("Unit tests for DefaultMcpServer") public class DefaultMcpServerTest { private ToolExecuteService toolExecuteService; + private McpSyncServer mcpSyncServer; @BeforeEach void setup() { this.toolExecuteService = mock(ToolExecuteService.class); + this.mcpSyncServer = mock(McpSyncServer.class); } @Nested @@ -51,7 +54,7 @@ class GivenConstructor { @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { IllegalArgumentException exception = - catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpServer(null)); + catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpServer(null, mcpSyncServer)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -62,17 +65,10 @@ class GivenGetInfo { @Test @DisplayName("Should return expected server information") void returnExpectedServerInfo() { - McpServer server = new DefaultMcpServer(toolExecuteService); + McpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); ServerSchema info = server.getSchema(); assertThat(info).returns("2025-06-18", ServerSchema::protocolVersion); - - McpSchema.ServerCapabilities capabilities = info.capabilities(); - assertThat(capabilities).isNotNull(); - - McpSchema.Implementation serverInfo = info.serverInfo(); - assertThat(serverInfo).returns("FIT Store MCP Server", McpSchema.Implementation::name) - .returns("3.6.0-SNAPSHOT", McpSchema.Implementation::version); } } @@ -82,7 +78,7 @@ class GivenRegisterAndNotify { @Test @DisplayName("Should notify observers when tools are added or removed") void notifyObserversOnToolAddOrRemove() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); @@ -105,7 +101,7 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get() @@ -127,7 +123,7 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -151,7 +147,7 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -167,7 +163,7 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) From e6de1a55bf63f16474d2610e30a91fa975dcaf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 23 Oct 2025 11:24:33 +0800 Subject: [PATCH 18/37] =?UTF-8?q?=E4=BF=AE=E5=A4=8DGet=E7=BB=93=E6=9D=9FEm?= =?UTF-8?q?itter=E4=B8=8Dclose=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/DefaultMcpServer.java | 6 +-- ...tMcpStreamableServerTransportProvider.java | 44 ++++++++++++++++++- .../fel/tool/mcp/server/McpServer.java | 1 - .../server/support/DefaultMcpServerTest.java | 1 - 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 1a32400f9..86881abb6 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -6,7 +6,6 @@ package modelengine.fel.tool.mcp.server; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; @@ -14,14 +13,12 @@ import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.service.ToolChangedObserver; import modelengine.fel.tool.service.ToolExecuteService; -import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fitframework.log.Logger; import modelengine.fitframework.util.MapUtils; import modelengine.fitframework.util.StringUtils; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -34,7 +31,8 @@ import static modelengine.fitframework.inspection.Validation.notNull; /** - * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} with MCP SDK. + * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} + * with MCP Server Bean {@link McpSyncServer}. * * @author 黄可欣 * @since 2025-09-30 diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java index eabb85a28..f598d207c 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java @@ -278,14 +278,17 @@ public void onEmittedData(TextEvent data) { @Override public void onCompleted() { logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); - listeningStream.close(); } @Override public void onFailed(Exception cause) { - // No action needed + logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, cause.getMessage()); } }); + + // Add connection monitoring to detect client disconnection + // This is a workaround to ensure listeningStream.close() is called when client disconnects + startConnectionMonitoring(sessionId, listeningStream, response); } }); } @@ -296,6 +299,43 @@ public void onFailed(Exception cause) { } } + /** + * Starts connection monitoring to detect client disconnection and ensure proper cleanup. + * This is a workaround to ensure listeningStream.close() is called when client disconnects. + * + * @param sessionId The session ID + * @param listeningStream The listening stream to close when connection is lost + * @param response The HTTP response to check for connection status + */ + private void startConnectionMonitoring(String sessionId, + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream, + HttpClassicServerResponse response) { + // Use a separate thread to periodically check connection status + Thread monitoringThread = new Thread(() -> { + try { + while (!Thread.currentThread().isInterrupted()) { + Thread.sleep(1000); // Check every second + + // Check if the HTTP response is still active + if (!response.isActive()) { + logger.info("[SSE] Connection lost for session, completing emitter to trigger cleanup"); + listeningStream.close(); + break; + } + } + } catch (InterruptedException e) { + logger.debug("[SSE] Connection monitoring interrupted for session"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + logger.warn("[SSE] Error in connection monitoring: {}", e.getMessage()); + } + }); + + monitoringThread.setDaemon(true); + monitoringThread.setName("sse-connection-monitor-" + sessionId); + monitoringThread.start(); + } + /** * Handles POST requests for incoming JSON-RPC messages from clients. * diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java index 6134726c0..29d468c22 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java @@ -12,7 +12,6 @@ import modelengine.fel.tool.mcp.entity.Tool; import java.util.List; -import java.util.Map; import java.util.function.BiFunction; /** diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index fb069a86d..f28ad5449 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -13,7 +13,6 @@ import static org.mockito.Mockito.verify; import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.mcp.server.DefaultMcpServer; From 43eac7dbda486d900ef544aeaee75e6cf88cda02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 23 Oct 2025 16:55:25 +0800 Subject: [PATCH 19/37] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/DefaultMcpServer.java | 62 +++++++------------ .../tool/mcp/server/DefaultMcpServerBean.java | 3 - .../fel/tool/mcp/server/McpServer.java | 21 ------- .../server/support/DefaultMcpServerTest.java | 10 +++ .../fel/java/services/tool-mcp-common/pom.xml | 7 --- .../fel/tool/mcp/entity/ServerSchema.java | 36 ++++++++--- .../modelengine/fel/tool/mcp/entity/Tool.java | 13 ++-- 7 files changed, 64 insertions(+), 88 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 86881abb6..21abc69bb 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -7,7 +7,6 @@ package modelengine.fel.tool.mcp.server; import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; @@ -23,7 +22,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BiFunction; import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; @@ -59,8 +57,10 @@ public DefaultMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcp @Override public ServerSchema getSchema() { - McpSchema.Implementation info = this.mcpSyncServer.getServerInfo(); - McpSchema.ServerCapabilities capabilities= this.mcpSyncServer.getServerCapabilities(); + ServerSchema.Info info = new ServerSchema.Info("FIT Store MCP Server", "3.6.0-SNAPSHOT"); + ServerSchema.Capabilities.Logging logging = new ServerSchema.Capabilities.Logging(); + ServerSchema.Capabilities.Tools tools = new ServerSchema.Capabilities.Tools(true); + ServerSchema.Capabilities capabilities = new ServerSchema.Capabilities(logging, tools); return new ServerSchema("2025-06-18", capabilities, info); } @@ -77,8 +77,7 @@ public void registerToolsChangedObserver(ToolsChangedObserver observer) { } @Override - public void addTool(String name, String description, McpSchema.JsonSchema inputSchema, - BiFunction callHandler) { + public void onToolAdded(String name, String description, Map parameters) { if (StringUtils.isBlank(name)) { log.warn("Tool addition is ignored: tool name is blank."); return; @@ -87,36 +86,45 @@ public void addTool(String name, String description, McpSchema.JsonSchema inputS log.warn("Tool addition is ignored: tool description is blank. [toolName={}]", name); return; } - if (inputSchema == null) { + if (MapUtils.isEmpty(parameters)) { log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); return; } - if (callHandler == null) { - log.warn("Tool addition is ignored: tool call handler is null or empty. [toolName={}]", name); + if (!(parameters.get(TYPE) instanceof String) + || parameters.get(PROPERTIES) != null && !(parameters.get(PROPERTIES) instanceof Map) + || parameters.get(REQUIRED) != null && !(parameters.get(REQUIRED) instanceof List)) { + log.warn("Invalid parameter schema. [toolName={}]", name); return; } - + @SuppressWarnings("unchecked") + McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema((String) parameters.get(TYPE), + (Map) parameters.get(PROPERTIES), (List) parameters.get(REQUIRED), + null, null,null); McpServerFeatures.SyncToolSpecification toolSpecification = McpServerFeatures.SyncToolSpecification.builder() .tool(McpSchema.Tool.builder() .name(name) .description(description) .inputSchema(inputSchema) .build()) - .callHandler(callHandler) + .callHandler((exchange, request) -> { + Map args = request.arguments(); + String result = this.toolExecuteService.execute(name, args); + return new McpSchema.CallToolResult(result, false); + }) .build(); this.mcpSyncServer.addTool(toolSpecification); Tool tool = new Tool(); tool.setName(name); tool.setDescription(description); - tool.setInputSchema(inputSchema); + tool.setInputSchema(parameters); this.tools.put(name, tool); log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, inputSchema); this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } @Override - public void removeTool(String name) { + public void onToolRemoved(String name) { if (StringUtils.isBlank(name)) { log.warn("Tool removal is ignored: tool name is blank."); return; @@ -126,32 +134,4 @@ public void removeTool(String name) { log.info("Tool removed from MCP server. [toolName={}]", name); this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } - - @Override - public void onToolAdded(String name, String description, Map parameters) { - if (MapUtils.isEmpty(parameters)) { - log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); - return; - } - if (!(parameters.get(TYPE) instanceof String) - || parameters.get(PROPERTIES) != null && !(parameters.get(PROPERTIES) instanceof Map) - || parameters.get(REQUIRED) != null && !(parameters.get(REQUIRED) instanceof List)) { - log.warn("Invalid parameter schema. [toolName={}]", name); - return; - } - @SuppressWarnings("unchecked") - McpSchema.JsonSchema hkxSchema = new McpSchema.JsonSchema((String) parameters.get(TYPE), - (Map) parameters.get(PROPERTIES), (List) parameters.get(REQUIRED), - null, null,null); - this.addTool(name, description, hkxSchema, (exchange, request) -> { - Map args = request.arguments(); - String result = this.toolExecuteService.execute(name, args); - return new McpSchema.CallToolResult(result, false); - }); - } - - @Override - public void onToolRemoved(String name) { - this.removeTool(name); - } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java index 5c6c9cf7a..50ade4ee8 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java @@ -34,11 +34,8 @@ public McpSyncServer mcpSyncServer(DefaultMcpStreamableServerTransportProvider t return io.modelcontextprotocol.server.McpServer.sync(transportProvider) .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") .capabilities(McpSchema.ServerCapabilities.builder() - .resources(false, true) // Enable resource support .tools(true) // Enable tool support - .prompts(true) // Enable prompt support .logging() // Enable logging support - .completions() // Enable completions support .build()) .requestTimeout(Duration.ofSeconds(10)) .build(); diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java index 29d468c22..25dd5daf0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java @@ -6,13 +6,10 @@ package modelengine.fel.tool.mcp.server; -import io.modelcontextprotocol.server.McpSyncServerExchange; -import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import java.util.List; -import java.util.function.BiFunction; /** * Represents the MCP Server. @@ -35,24 +32,6 @@ public interface McpServer { */ List getTools(); - /** - * Add a tool. - * - * @param name The name of the added tool, as a {@link String}. - * @param description A description of the added tool, as a {@link String}. - * @param inputSchema The parameters associated with the added tool, as a {@link McpSchema.JsonSchema}. - * @param callHandler The tool call handler as a {@link BiFunction} - */ - void addTool(String name, String description, McpSchema.JsonSchema inputSchema, - BiFunction callHandler); - - /** - * Remove a tool. - * - * @param name The name of the removed tool, as a {@link String}. - */ - void removeTool(String name); - /** * Registers MCP server tools changed observer. * diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index f28ad5449..27b633987 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -68,6 +68,16 @@ void returnExpectedServerInfo() { ServerSchema info = server.getSchema(); assertThat(info).returns("2025-06-18", ServerSchema::protocolVersion); + + ServerSchema.Capabilities capabilities = info.capabilities(); + assertThat(capabilities).isNotNull(); + + ServerSchema.Capabilities.Tools toolsCapability = capabilities.tools(); + assertThat(toolsCapability).returns(true, ServerSchema.Capabilities.Tools::listChanged); + + ServerSchema.Info serverInfo = info.serverInfo(); + assertThat(serverInfo).returns("FIT Store MCP Server", ServerSchema.Info::name) + .returns("3.6.0-SNAPSHOT", ServerSchema.Info::version); } } diff --git a/framework/fel/java/services/tool-mcp-common/pom.xml b/framework/fel/java/services/tool-mcp-common/pom.xml index 8823d6964..d18215525 100644 --- a/framework/fel/java/services/tool-mcp-common/pom.xml +++ b/framework/fel/java/services/tool-mcp-common/pom.xml @@ -37,12 +37,5 @@ org.assertj assertj-core - - - - io.modelcontextprotocol.sdk - mcp - 0.14.0 - \ No newline at end of file diff --git a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java index b641de68e..7125c5602 100644 --- a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java +++ b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/ServerSchema.java @@ -6,12 +6,10 @@ package modelengine.fel.tool.mcp.entity; -import io.modelcontextprotocol.spec.McpSchema; +import static modelengine.fitframework.util.ObjectUtils.cast; import java.util.Map; -import static modelengine.fitframework.util.ObjectUtils.cast; - /** * Represents a server entity in the MCP framework, encapsulating information about the server's protocol version, * capabilities, and additional server details. @@ -19,7 +17,7 @@ * @author 季聿阶 * @since 2025-05-22 */ -public record ServerSchema(String protocolVersion, McpSchema.ServerCapabilities capabilities, McpSchema.Implementation serverInfo) { +public record ServerSchema(String protocolVersion, Capabilities capabilities, Info serverInfo) { /** * Creates a new {@link ServerSchema} instance based on the provided map of server information. * @@ -29,17 +27,35 @@ public record ServerSchema(String protocolVersion, McpSchema.ServerCapabilities public static ServerSchema create(Map map) { String protocolVersion = cast(map.get("protocolVersion")); Map capabilitiesMap = cast(map.get("capabilities")); + Capabilities.Logging logging = new Capabilities.Logging(); Map toolsMap = cast(capabilitiesMap.get("tools")); boolean toolsListChanged = cast(toolsMap.getOrDefault("listChanged", false)); - McpSchema.ServerCapabilities capabilities = McpSchema.ServerCapabilities.builder() - .tools(toolsListChanged) // Enable tool support - .logging() // Enable logging support - .completions() // Enable completions support - .build(); + Capabilities.Tools tools = new Capabilities.Tools(toolsListChanged); + Capabilities capabilities = new Capabilities(logging, tools); Map infoMap = cast(map.get("serverInfo")); String name = cast(infoMap.get("name")); String version = cast(infoMap.get("version")); - McpSchema.Implementation serverInfo = new McpSchema.Implementation(name, version); + Info serverInfo = new Info(name, version); return new ServerSchema(protocolVersion, capabilities, serverInfo); } + + /** + * Represents the capabilities supported by the server, including logging and tool-related functionalities. + */ + public record Capabilities(Logging logging, Tools tools) { + /** + * Represents the logging capabilities of the server. + */ + public record Logging() {} + + /** + * Represents the tool-related capabilities of the server, including whether the tool list has changed. + */ + public record Tools(boolean listChanged) {} + } + + /** + * Represents additional information about the server, such as its name and version. + */ + public record Info(String name, String version) {} } \ No newline at end of file diff --git a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java index a712c5ed4..0b7c0f692 100644 --- a/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java +++ b/framework/fel/java/services/tool-mcp-common/src/main/java/modelengine/fel/tool/mcp/entity/Tool.java @@ -6,7 +6,7 @@ package modelengine.fel.tool.mcp.entity; -import io.modelcontextprotocol.spec.McpSchema; +import java.util.Map; /** * Represents a tool entity with name, description, and schema. @@ -31,7 +31,7 @@ public class Tool { * The input schema that defines the expected parameters when invoking the tool. * Typically represented using a map structure, e.g., JSON schema format. */ - private McpSchema.JsonSchema inputSchema; + private Map inputSchema; /** * Gets the name of the tool. @@ -73,18 +73,19 @@ public void setDescription(String description) { * Gets the input schema of the tool. * This defines the required and optional parameters for invoking the tool. * - * @return The tool's input schema as a {@link McpSchema.JsonSchema}. + * @return The tool's input schema as a {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. */ - public McpSchema.JsonSchema getInputSchema() { + public Map getInputSchema() { return this.inputSchema; } /** * Sets the input schema of the tool. * - * @param inputSchema The new input schema for the tool, as a {@link McpSchema.JsonSchema}. + * @param inputSchema The new input schema for the tool, as a + * {@link Map}{@code <}{@link String}{@code , }{@link Object}{@code >}. */ - public void setInputSchema(McpSchema.JsonSchema inputSchema) { + public void setInputSchema(Map inputSchema) { this.inputSchema = inputSchema; } } From deb5e92325b50950d993ac34e56d27d89709b6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 23 Oct 2025 17:20:23 +0800 Subject: [PATCH 20/37] =?UTF-8?q?test=E5=8F=98=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/DefaultMcpServer.java | 10 +++++----- .../tool/mcp/server/support/DefaultMcpServerTest.java | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 21abc69bb..3dc2bd7d0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -6,6 +6,7 @@ package modelengine.fel.tool.mcp.server; +import static modelengine.fitframework.inspection.Validation.notNull; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; @@ -26,22 +27,21 @@ import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; import static modelengine.fel.tool.info.schema.ToolsSchema.REQUIRED; -import static modelengine.fitframework.inspection.Validation.notNull; /** * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} * with MCP Server Bean {@link McpSyncServer}. * - * @author 黄可欣 - * @since 2025-09-30 + * @author 季聿阶 + * @since 2025-05-15 */ @Component public class DefaultMcpServer implements McpServer, ToolChangedObserver { private static final Logger log = Logger.get(DefaultMcpServer.class); private final McpSyncServer mcpSyncServer; - private final Map tools = new ConcurrentHashMap<>(); private final ToolExecuteService toolExecuteService; + private final Map tools = new ConcurrentHashMap<>(); private final List toolsChangedObservers = new ArrayList<>(); /** @@ -119,7 +119,7 @@ public void onToolAdded(String name, String description, Map par tool.setDescription(description); tool.setInputSchema(parameters); this.tools.put(name, tool); - log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, inputSchema); + log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index 27b633987..ec3e03c2f 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java @@ -127,6 +127,7 @@ void addToolSuccessfully() { Tool tool = tools.get(0); assertThat(tool.getName()).isEqualTo(name); assertThat(tool.getDescription()).isEqualTo(description); + assertThat(tool.getInputSchema()).isEqualTo(schema); } @Test From a954a5ede631a3c1aafd60879ae2c61a1322f9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Mon, 27 Oct 2025 10:33:39 +0800 Subject: [PATCH 21/37] =?UTF-8?q?=E4=BD=BF=E7=94=A8logback-classic?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E7=BB=99SLF4J?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/java/plugins/tool-mcp-server/pom.xml | 7 +++++++ .../src/main/resources/logback.xml | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml diff --git a/framework/fel/java/plugins/tool-mcp-server/pom.xml b/framework/fel/java/plugins/tool-mcp-server/pom.xml index 0d2a4ff70..266cc82de 100644 --- a/framework/fel/java/plugins/tool-mcp-server/pom.xml +++ b/framework/fel/java/plugins/tool-mcp-server/pom.xml @@ -47,6 +47,13 @@ 0.14.0 + + + ch.qos.logback + logback-classic + 1.5.6 + + org.junit.jupiter diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml new file mode 100644 index 000000000..93046ac4c --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + [%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] [%logger] %msg%n + + + + + + + + + + \ No newline at end of file From ca3555be47802d2272a6198b00efb2b0b18cfe3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Mon, 27 Oct 2025 15:37:04 +0800 Subject: [PATCH 22/37] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/DefaultMcpServer.java | 20 ++++-- .../tool/mcp/server/DefaultMcpServerBean.java | 4 +- ...tMcpStreamableServerTransportProvider.java | 71 +++++++------------ 3 files changed, 44 insertions(+), 51 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java index 3dc2bd7d0..29ae27508 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java @@ -90,9 +90,13 @@ public void onToolAdded(String name, String description, Map par log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); return; } + Object props = parameters.get(PROPERTIES); + Object reqs = parameters.get(REQUIRED); if (!(parameters.get(TYPE) instanceof String) - || parameters.get(PROPERTIES) != null && !(parameters.get(PROPERTIES) instanceof Map) - || parameters.get(REQUIRED) != null && !(parameters.get(REQUIRED) instanceof List)) { + || (props != null && (!(props instanceof Map) + || ((Map) props).keySet().stream().anyMatch(k -> !(k instanceof String)))) + || (reqs != null && (!(reqs instanceof List) + || ((List) reqs).stream().anyMatch(v -> !(v instanceof String))))) { log.warn("Invalid parameter schema. [toolName={}]", name); return; } @@ -112,13 +116,19 @@ public void onToolAdded(String name, String description, Map par return new McpSchema.CallToolResult(result, false); }) .build(); - this.mcpSyncServer.addTool(toolSpecification); - Tool tool = new Tool(); tool.setName(name); tool.setDescription(description); tool.setInputSchema(parameters); - this.tools.put(name, tool); + + try { + this.mcpSyncServer.addTool(toolSpecification); + this.tools.put(name, tool); + } catch (Exception e) { + log.error("Failed to add tool: {}", name, e); + this.tools.remove(name); + return; + } log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java index 50ade4ee8..039e54c37 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java @@ -22,6 +22,8 @@ */ @Component public class DefaultMcpServerBean { + private final static Duration requestTimeout = Duration.ofSeconds(10); + @Bean public DefaultMcpStreamableServerTransportProvider defaultMcpStreamableServerTransportProvider() { return DefaultMcpStreamableServerTransportProvider.builder() @@ -37,7 +39,7 @@ public McpSyncServer mcpSyncServer(DefaultMcpStreamableServerTransportProvider t .tools(true) // Enable tool support .logging() // Enable logging support .build()) - .requestTimeout(Duration.ofSeconds(10)) + .requestTimeout(requestTimeout) .build(); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java index f598d207c..e22f9c1f3 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java @@ -235,7 +235,7 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo // TODO emitter.onTimeout() logger.info() DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( - sessionId, emitter); + sessionId, emitter, response); // Check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { @@ -278,17 +278,23 @@ public void onEmittedData(TextEvent data) { @Override public void onCompleted() { logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); + try { + listeningStream.close(); + } catch (Exception e) { + logger.warn("[SSE] Error closing listeningStream on complete: {}", e.getMessage()); + } } @Override public void onFailed(Exception cause) { logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, cause.getMessage()); + try { + listeningStream.close(); + } catch (Exception e) { + logger.warn("[SSE] Error closing listeningStream on failure: {}", e.getMessage()); + } } }); - - // Add connection monitoring to detect client disconnection - // This is a workaround to ensure listeningStream.close() is called when client disconnects - startConnectionMonitoring(sessionId, listeningStream, response); } }); } @@ -299,43 +305,6 @@ public void onFailed(Exception cause) { } } - /** - * Starts connection monitoring to detect client disconnection and ensure proper cleanup. - * This is a workaround to ensure listeningStream.close() is called when client disconnects. - * - * @param sessionId The session ID - * @param listeningStream The listening stream to close when connection is lost - * @param response The HTTP response to check for connection status - */ - private void startConnectionMonitoring(String sessionId, - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream, - HttpClassicServerResponse response) { - // Use a separate thread to periodically check connection status - Thread monitoringThread = new Thread(() -> { - try { - while (!Thread.currentThread().isInterrupted()) { - Thread.sleep(1000); // Check every second - - // Check if the HTTP response is still active - if (!response.isActive()) { - logger.info("[SSE] Connection lost for session, completing emitter to trigger cleanup"); - listeningStream.close(); - break; - } - } - } catch (InterruptedException e) { - logger.debug("[SSE] Connection monitoring interrupted for session"); - Thread.currentThread().interrupt(); - } catch (Exception e) { - logger.warn("[SSE] Error in connection monitoring: {}", e.getMessage()); - } - }); - - monitoringThread.setDaemon(true); - monitoringThread.setName("sse-connection-monitor-" + sessionId); - monitoringThread.start(); - } - /** * Handles POST requests for incoming JSON-RPC messages from clients. * @@ -449,11 +418,11 @@ public void onCompleted() { @Override public void onFailed(Exception e) { - // No action needed + logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, e.getMessage()); } }); - DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport(sessionId, emitter); + DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport(sessionId, emitter, response); try { session.responseStream(jsonrpcRequest, sessionTransport) @@ -571,15 +540,20 @@ private class DefaultStreamableMcpSessionTransport implements McpStreamableServe private final ReentrantLock lock = new ReentrantLock(); private volatile boolean closed = false; + + private final HttpClassicServerResponse response; /** * Creates a new session transport with the specified ID and SSE builder. * * @param sessionId The unique identifier for this session + * @param emitter The emitter for sending events + * @param response The HTTP response for checking connection status */ - DefaultStreamableMcpSessionTransport(String sessionId, Emitter emitter) { + DefaultStreamableMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { this.sessionId = sessionId; this.emitter = emitter; + this.response = response; logger.info("[SSE] Building SSE for session: {} ", sessionId); } @@ -616,6 +590,13 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId logger.info("Session {} was closed during message send attempt", this.sessionId); return; } + + // Check if connection is still active before sending + if (!this.response.isActive()) { + logger.warn("[SSE] Connection inactive detected while sending message for session: {}", this.sessionId); + this.close(); + return; + } String jsonText = objectMapper.writeValueAsString(message); TextEvent textEvent = TextEvent.custom() From 0af7a762b1828a439d10ffadd12fdf09e0aa7487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 29 Oct 2025 16:52:04 +0800 Subject: [PATCH 23/37] =?UTF-8?q?SLF4J=E4=BE=9D=E8=B5=96=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/java/plugins/tool-mcp-server/pom.xml | 19 ++++++++++-------- ...r.java => DefaultMcpStreamableServer.java} | 6 +++--- .../{ => bean}/DefaultMcpServerBean.java | 9 +++++---- ...McpStreamableServerTransportProvider.java} | 19 +++++++++--------- .../src/main/resources/logback.xml | 17 ---------------- ...va => DefaultMcpStreamableServerTest.java} | 20 +++++++++---------- .../fitframework/flowable/Emitter.java | 2 -- 7 files changed, 38 insertions(+), 54 deletions(-) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{DefaultMcpServer.java => DefaultMcpStreamableServer.java} (95%) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{ => bean}/DefaultMcpServerBean.java (79%) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{DefaultMcpStreamableServerTransportProvider.java => transport/FitMcpStreamableServerTransportProvider.java} (97%) delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml rename framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/{DefaultMcpServerTest.java => DefaultMcpStreamableServerTest.java} (88%) diff --git a/framework/fel/java/plugins/tool-mcp-server/pom.xml b/framework/fel/java/plugins/tool-mcp-server/pom.xml index 266cc82de..42866b151 100644 --- a/framework/fel/java/plugins/tool-mcp-server/pom.xml +++ b/framework/fel/java/plugins/tool-mcp-server/pom.xml @@ -44,14 +44,7 @@ io.modelcontextprotocol.sdk mcp - 0.14.0 - - - - - ch.qos.logback - logback-classic - 1.5.6 + 0.14.1 @@ -81,6 +74,16 @@ system 5 + + + org.slf4j + slf4j-api + + + org.slf4j + slf4j-simple + + diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java similarity index 95% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java index 29ae27508..2ac167fb5 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java @@ -36,8 +36,8 @@ * @since 2025-05-15 */ @Component -public class DefaultMcpServer implements McpServer, ToolChangedObserver { - private static final Logger log = Logger.get(DefaultMcpServer.class); +public class DefaultMcpStreamableServer implements McpServer, ToolChangedObserver { + private static final Logger log = Logger.get(DefaultMcpStreamableServer.class); private final McpSyncServer mcpSyncServer; private final ToolExecuteService toolExecuteService; @@ -50,7 +50,7 @@ public class DefaultMcpServer implements McpServer, ToolChangedObserver { * @param toolExecuteService The service used to execute tools when handling tool call requests. * @throws IllegalArgumentException If {@code toolExecuteService} is null. */ - public DefaultMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + public DefaultMcpStreamableServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); this.mcpSyncServer = mcpSyncServer; } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/bean/DefaultMcpServerBean.java similarity index 79% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/bean/DefaultMcpServerBean.java index 039e54c37..d70d52b32 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpServerBean.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/bean/DefaultMcpServerBean.java @@ -4,11 +4,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -package modelengine.fel.tool.mcp.server; +package modelengine.fel.tool.mcp.server.bean; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; @@ -25,14 +26,14 @@ public class DefaultMcpServerBean { private final static Duration requestTimeout = Duration.ofSeconds(10); @Bean - public DefaultMcpStreamableServerTransportProvider defaultMcpStreamableServerTransportProvider() { - return DefaultMcpStreamableServerTransportProvider.builder() + public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { + return FitMcpStreamableServerTransportProvider.builder() .objectMapper(new ObjectMapper()) .build(); } @Bean - public McpSyncServer mcpSyncServer(DefaultMcpStreamableServerTransportProvider transportProvider) { + public McpSyncServer mcpSyncServer(FitMcpStreamableServerTransportProvider transportProvider) { return io.modelcontextprotocol.server.McpServer.sync(transportProvider) .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") .capabilities(McpSchema.ServerCapabilities.builder() diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java similarity index 97% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index e22f9c1f3..43b76c1a6 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -package modelengine.fel.tool.mcp.server; +package modelengine.fel.tool.mcp.server.transport; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -44,9 +44,8 @@ * @author 黄可欣 * @since 2025-09-30 */ -public class DefaultMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { - - private static final Logger logger = Logger.get(DefaultMcpStreamableServerTransportProvider.class); +public class FitMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { + private static final Logger logger = Logger.get(FitMcpStreamableServerTransportProvider.class); private static final String MESSAGE_ENDPOINT = "/mcp/streamable"; @@ -90,7 +89,7 @@ public class DefaultMcpStreamableServerTransportProvider implements McpStreamabl /** * Constructs a new DefaultMcpStreamableServerTransportProvider instance, - * for {@link DefaultMcpStreamableServerTransportProvider.Builder}. + * for {@link FitMcpStreamableServerTransportProvider.Builder}. * * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization * of messages. @@ -99,7 +98,7 @@ public class DefaultMcpStreamableServerTransportProvider implements McpStreamabl * @param keepAliveInterval The interval for sending keep-alive messages to clients. * @throws IllegalArgumentException if any parameter is null */ - private DefaultMcpStreamableServerTransportProvider(ObjectMapper objectMapper, + private FitMcpStreamableServerTransportProvider(ObjectMapper objectMapper, boolean disallowDelete, McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { @@ -683,7 +682,7 @@ public static Builder builder() { } /** - * Builder for creating instances of {@link DefaultMcpStreamableServerTransportProvider}. + * Builder for creating instances of {@link FitMcpStreamableServerTransportProvider}. */ public static class Builder { @@ -751,16 +750,16 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { } /** - * Builds a new instance of {@link DefaultMcpStreamableServerTransportProvider} with + * Builds a new instance of {@link FitMcpStreamableServerTransportProvider} with * the configured settings. * * @return A new DefaultMcpStreamableServerTransportProvider instance * @throws IllegalStateException if required parameters are not set */ - public DefaultMcpStreamableServerTransportProvider build() { + public FitMcpStreamableServerTransportProvider build() { Assert.notNull(this.objectMapper, "ObjectMapper must be set"); - return new DefaultMcpStreamableServerTransportProvider(this.objectMapper, this.disallowDelete, + return new FitMcpStreamableServerTransportProvider(this.objectMapper, this.disallowDelete, this.contextExtractor, this.keepAliveInterval); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml deleted file mode 100644 index 93046ac4c..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - [%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] [%logger] %msg%n - - - - - - - - - - \ No newline at end of file diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java similarity index 88% rename from framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java rename to framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java index ec3e03c2f..1d7fadd33 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java @@ -15,7 +15,7 @@ import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; -import modelengine.fel.tool.mcp.server.DefaultMcpServer; +import modelengine.fel.tool.mcp.server.DefaultMcpStreamableServer; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; @@ -30,13 +30,13 @@ import java.util.Map; /** - * Unit test for {@link DefaultMcpServer}. + * Unit test for {@link DefaultMcpStreamableServer}. * * @author 季聿阶 * @since 2025-05-20 */ @DisplayName("Unit tests for DefaultMcpServer") -public class DefaultMcpServerTest { +public class DefaultMcpStreamableServerTest { private ToolExecuteService toolExecuteService; private McpSyncServer mcpSyncServer; @@ -53,7 +53,7 @@ class GivenConstructor { @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { IllegalArgumentException exception = - catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpServer(null, mcpSyncServer)); + catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpStreamableServer(null, mcpSyncServer)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -64,7 +64,7 @@ class GivenGetInfo { @Test @DisplayName("Should return expected server information") void returnExpectedServerInfo() { - McpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + McpServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); ServerSchema info = server.getSchema(); assertThat(info).returns("2025-06-18", ServerSchema::protocolVersion); @@ -87,7 +87,7 @@ class GivenRegisterAndNotify { @Test @DisplayName("Should notify observers when tools are added or removed") void notifyObserversOnToolAddOrRemove() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); @@ -110,7 +110,7 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get() @@ -133,7 +133,7 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -157,7 +157,7 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -173,7 +173,7 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) diff --git a/framework/fit/java/fit-reactor/src/main/java/modelengine/fitframework/flowable/Emitter.java b/framework/fit/java/fit-reactor/src/main/java/modelengine/fitframework/flowable/Emitter.java index 9a4276b87..a88958f76 100644 --- a/framework/fit/java/fit-reactor/src/main/java/modelengine/fitframework/flowable/Emitter.java +++ b/framework/fit/java/fit-reactor/src/main/java/modelengine/fitframework/flowable/Emitter.java @@ -76,7 +76,5 @@ interface Observer { * @param cause 表示失败原因的 {@link Exception}。 */ void onFailed(Exception cause); - - // TODO onTimeout()方法 } } From 93cb619d634e1327eb00acc13da542c5d34dcff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 29 Oct 2025 17:04:10 +0800 Subject: [PATCH 24/37] Optimize imports --- .../tool/mcp/server/DefaultMcpStreamableServer.java | 4 ++-- .../FitMcpStreamableServerTransportProvider.java | 7 +++++-- .../support/DefaultMcpStreamableServerTest.java | 11 ++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java index 2ac167fb5..7d784bcbe 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java @@ -6,15 +6,14 @@ package modelengine.fel.tool.mcp.server; -import static modelengine.fitframework.inspection.Validation.notNull; import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.service.ToolChangedObserver; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Component; -import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fitframework.log.Logger; import modelengine.fitframework.util.MapUtils; import modelengine.fitframework.util.StringUtils; @@ -27,6 +26,7 @@ import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; import static modelengine.fel.tool.info.schema.ToolsSchema.REQUIRED; +import static modelengine.fitframework.inspection.Validation.notNull; /** * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 43b76c1a6..665e599aa 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -15,9 +15,12 @@ import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; -import modelengine.fit.http.entity.TextEvent; -import modelengine.fit.http.annotation.*; +import modelengine.fit.http.annotation.DeleteMapping; +import modelengine.fit.http.annotation.GetMapping; +import modelengine.fit.http.annotation.PostMapping; +import modelengine.fit.http.annotation.RequestBody; import modelengine.fit.http.entity.Entity; +import modelengine.fit.http.entity.TextEvent; import modelengine.fit.http.protocol.HttpResponseStatus; import modelengine.fit.http.protocol.MessageHeaderNames; import modelengine.fit.http.protocol.MimeType; diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java index 1d7fadd33..d7daa4cc3 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java @@ -6,12 +6,6 @@ package modelengine.fel.tool.mcp.server.support; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowableOfType; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; @@ -19,7 +13,6 @@ import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -29,6 +22,10 @@ import java.util.List; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.*; + /** * Unit test for {@link DefaultMcpStreamableServer}. * From 7cc67f40446a106223bd362dd94d257a9c3ad2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 29 Oct 2025 17:21:37 +0800 Subject: [PATCH 25/37] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=B1=BB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FitMcpStreamableServerTransportProvider.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 665e599aa..9fa26cc80 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -91,7 +91,7 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer private KeepAliveScheduler keepAliveScheduler; /** - * Constructs a new DefaultMcpStreamableServerTransportProvider instance, + * Constructs a new FitMcpStreamableServerTransportProvider instance, * for {@link FitMcpStreamableServerTransportProvider.Builder}. * * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization @@ -236,7 +236,7 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo return Choir.create(emitter -> { // TODO emitter.onTimeout() logger.info() - DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport( + FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport( sessionId, emitter, response); // Check if this is a replay request @@ -424,7 +424,7 @@ public void onFailed(Exception e) { } }); - DefaultStreamableMcpSessionTransport sessionTransport = new DefaultStreamableMcpSessionTransport(sessionId, emitter, response); + FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport(sessionId, emitter, response); try { session.responseStream(jsonrpcRequest, sessionTransport) @@ -533,7 +533,7 @@ else if (map.containsKey("result") || map.containsKey("error")) { * underlying SSE builder to prevent race conditions when multiple threads attempt to * send messages concurrently. */ - private class DefaultStreamableMcpSessionTransport implements McpStreamableServerTransport { + private class FitStreamableMcpSessionTransport implements McpStreamableServerTransport { private final String sessionId; @@ -552,7 +552,7 @@ private class DefaultStreamableMcpSessionTransport implements McpStreamableServe * @param emitter The emitter for sending events * @param response The HTTP response for checking connection status */ - DefaultStreamableMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { + FitStreamableMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { this.sessionId = sessionId; this.emitter = emitter; this.response = response; @@ -650,7 +650,7 @@ public Type getType() { */ @Override public Mono closeGracefully() { - return Mono.fromRunnable(DefaultStreamableMcpSessionTransport.this::close); + return Mono.fromRunnable(FitStreamableMcpSessionTransport.this::close); } /** @@ -756,7 +756,7 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { * Builds a new instance of {@link FitMcpStreamableServerTransportProvider} with * the configured settings. * - * @return A new DefaultMcpStreamableServerTransportProvider instance + * @return A new FitMcpStreamableServerTransportProvider instance * @throws IllegalStateException if required parameters are not set */ public FitMcpStreamableServerTransportProvider build() { From 0252343bc73101fdb134e60a3151a2b9c567eb72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 30 Oct 2025 11:05:52 +0800 Subject: [PATCH 26/37] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=9C=AC=E5=9C=B0Tools?= =?UTF-8?q?=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/java/plugins/tool-mcp-server/pom.xml | 10 -- ...cpServerBean.java => McpServerConfig.java} | 16 +-- .../DefaultMcpStreamableServer.java | 105 +++++++++++++----- ...tMcpStreamableServerTransportProvider.java | 12 -- .../src/main/resources/application.yml | 7 +- .../DefaultMcpStreamableServerTest.java | 3 +- 6 files changed, 95 insertions(+), 58 deletions(-) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{bean/DefaultMcpServerBean.java => McpServerConfig.java} (79%) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{ => support}/DefaultMcpStreamableServer.java (60%) diff --git a/framework/fel/java/plugins/tool-mcp-server/pom.xml b/framework/fel/java/plugins/tool-mcp-server/pom.xml index 42866b151..b6072ea4a 100644 --- a/framework/fel/java/plugins/tool-mcp-server/pom.xml +++ b/framework/fel/java/plugins/tool-mcp-server/pom.xml @@ -74,16 +74,6 @@ system 5 - - - org.slf4j - slf4j-api - - - org.slf4j - slf4j-simple - - diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/bean/DefaultMcpServerBean.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java similarity index 79% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/bean/DefaultMcpServerBean.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java index d70d52b32..2d39377b5 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/bean/DefaultMcpServerBean.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java @@ -4,7 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -package modelengine.fel.tool.mcp.server.bean; +package modelengine.fel.tool.mcp.server; import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.server.McpSyncServer; @@ -12,6 +12,7 @@ import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -22,9 +23,7 @@ * @since 2025-10-22 */ @Component -public class DefaultMcpServerBean { - private final static Duration requestTimeout = Duration.ofSeconds(10); - +public class McpServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { return FitMcpStreamableServerTransportProvider.builder() @@ -33,14 +32,15 @@ public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportPr } @Bean - public McpSyncServer mcpSyncServer(FitMcpStreamableServerTransportProvider transportProvider) { + public McpSyncServer mcpSyncServer(FitMcpStreamableServerTransportProvider transportProvider, + @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { return io.modelcontextprotocol.server.McpServer.sync(transportProvider) .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") .capabilities(McpSchema.ServerCapabilities.builder() - .tools(true) // Enable tool support - .logging() // Enable logging support + .tools(true) + .logging() .build()) - .requestTimeout(requestTimeout) + .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java similarity index 60% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java index 7d784bcbe..d1649b7b1 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/DefaultMcpStreamableServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java @@ -4,13 +4,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -package modelengine.fel.tool.mcp.server; +package modelengine.fel.tool.mcp.server.support; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; +import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolChangedObserver; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Component; @@ -19,9 +20,10 @@ import modelengine.fitframework.util.StringUtils; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; @@ -33,6 +35,7 @@ * with MCP Server Bean {@link McpSyncServer}. * * @author 季聿阶 + * @author 黄可欣 * @since 2025-05-15 */ @Component @@ -41,7 +44,6 @@ public class DefaultMcpStreamableServer implements McpServer, ToolChangedObserve private final McpSyncServer mcpSyncServer; private final ToolExecuteService toolExecuteService; - private final Map tools = new ConcurrentHashMap<>(); private final List toolsChangedObservers = new ArrayList<>(); /** @@ -66,7 +68,9 @@ public ServerSchema getSchema() { @Override public List getTools() { - return List.copyOf(this.tools.values()); + return this.mcpSyncServer.listTools().stream() + .map(this::convertToFelTool) + .collect(Collectors.toList()); } @Override @@ -90,13 +94,7 @@ public void onToolAdded(String name, String description, Map par log.warn("Tool addition is ignored: tool schema is null or empty. [toolName={}]", name); return; } - Object props = parameters.get(PROPERTIES); - Object reqs = parameters.get(REQUIRED); - if (!(parameters.get(TYPE) instanceof String) - || (props != null && (!(props instanceof Map) - || ((Map) props).keySet().stream().anyMatch(k -> !(k instanceof String)))) - || (reqs != null && (!(reqs instanceof List) - || ((List) reqs).stream().anyMatch(v -> !(v instanceof String))))) { + if (!isValidParameterSchema(parameters)) { log.warn("Invalid parameter schema. [toolName={}]", name); return; } @@ -111,26 +109,27 @@ public void onToolAdded(String name, String description, Map par .inputSchema(inputSchema) .build()) .callHandler((exchange, request) -> { - Map args = request.arguments(); - String result = this.toolExecuteService.execute(name, args); - return new McpSchema.CallToolResult(result, false); + try { + Map args = request.arguments(); + String result = this.toolExecuteService.execute(name, args); + return new McpSchema.CallToolResult(result, false); + } catch (IllegalArgumentException e) { + log.warn("Invalid arguments for tool execution. [toolName={}, error={}]", name, e.getMessage()); + return new McpSchema.CallToolResult("Error: Invalid arguments - " + e.getMessage(), true); + } catch (Exception e) { + log.error("Failed to execute tool. [toolName={}]", name, e); + return new McpSchema.CallToolResult("Error: Tool execution failed - " + e.getMessage(), true); + } }) .build(); - Tool tool = new Tool(); - tool.setName(name); - tool.setDescription(description); - tool.setInputSchema(parameters); try { this.mcpSyncServer.addTool(toolSpecification); - this.tools.put(name, tool); + log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); + this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } catch (Exception e) { - log.error("Failed to add tool: {}", name, e); - this.tools.remove(name); - return; + log.error("Failed to add tool to MCP server. [toolName={}]", name, e); } - log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); - this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } @Override @@ -140,8 +139,64 @@ public void onToolRemoved(String name) { return; } this.mcpSyncServer.removeTool(name); - this.tools.remove(name); log.info("Tool removed from MCP server. [toolName={}]", name); this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } + + /** + * Converts an MCP SDK Tool to a FEL Tool entity. + * + * @param mcpTool The MCP SDK tool to convert. + * @return A FEL Tool entity with the corresponding name, description, and input schema. + */ + private Tool convertToFelTool(McpSchema.Tool mcpTool) { + Tool tool = new Tool(); + tool.setName(mcpTool.name()); + tool.setDescription(mcpTool.description()); + + // Convert JsonSchema to Map + McpSchema.JsonSchema inputSchema = mcpTool.inputSchema(); + Map schemaMap = new HashMap<>(); + schemaMap.put(TYPE, inputSchema.type()); + if (inputSchema.properties() != null) { + schemaMap.put(PROPERTIES, inputSchema.properties()); + } + if (inputSchema.required() != null) { + schemaMap.put(REQUIRED, inputSchema.required()); + } + tool.setInputSchema(schemaMap); + + return tool; + } + + /** + * Validates the structure of the parameter schema to ensure it conforms to the expected format. + * + * @param parameters The parameter schema to validate, represented as a Map with String keys and Object values. + * @return {@code true} if the parameter schema is valid; {@code false} otherwise. + */ + private boolean isValidParameterSchema(Map parameters) { + Object type = parameters.get(TYPE); + if (!(type instanceof String)) { + return false; + } + + Object props = parameters.get(PROPERTIES); + if (!(props instanceof Map propsMap)) { + return false; + } + if (propsMap.keySet().stream().anyMatch(k -> !(k instanceof String))) { + return false; + } + + Object reqs = parameters.get(REQUIRED); + if (!(reqs instanceof List reqsList)) { + return false; + } + if (reqsList.stream().anyMatch(v -> !(v instanceof String))) { + return false; + } + + return true; + } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 9fa26cc80..44738a948 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -51,20 +51,8 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer private static final Logger logger = Logger.get(FitMcpStreamableServerTransportProvider.class); private static final String MESSAGE_ENDPOINT = "/mcp/streamable"; - - /** - * Event type for JSON-RPC messages sent through the SSE connection. - */ public static final String MESSAGE_EVENT_TYPE = "message"; - - /** - * Event type for sending the message endpoint URI to clients. - */ public static final String ENDPOINT_EVENT_TYPE = "endpoint"; - - /** - * Default base URL for the message endpoint. - */ public static final String DEFAULT_BASE_URL = ""; /** diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml index 64ea10351..6d6cfd452 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml @@ -1,4 +1,9 @@ fit: beans: packages: - - 'modelengine.fel.tool.mcp.server' \ No newline at end of file + - 'modelengine.fel.tool.mcp.server' + +mcp: + server: + request: + timeout-seconds: 10 \ No newline at end of file diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java index d7daa4cc3..4c5d1cdb0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java @@ -9,7 +9,6 @@ import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; -import modelengine.fel.tool.mcp.server.DefaultMcpStreamableServer; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; @@ -32,7 +31,7 @@ * @author 季聿阶 * @since 2025-05-20 */ -@DisplayName("Unit tests for DefaultMcpServer") +@DisplayName("Unit tests for DefaultMcpStreamableServer") public class DefaultMcpStreamableServerTest { private ToolExecuteService toolExecuteService; private McpSyncServer mcpSyncServer; From 8d855455a1e1491eef5bae918a7c604541a19c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 30 Oct 2025 11:24:34 +0800 Subject: [PATCH 27/37] =?UTF-8?q?ServerSchema=E6=97=A7=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/McpServer.java | 8 ----- .../support/DefaultMcpStreamableServer.java | 10 ------- ...tMcpStreamableServerTransportProvider.java | 29 +++++++------------ .../DefaultMcpStreamableServerTest.java | 28 ++---------------- 4 files changed, 13 insertions(+), 62 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java index 25dd5daf0..7febd4ddd 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java @@ -6,7 +6,6 @@ package modelengine.fel.tool.mcp.server; -import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import java.util.List; @@ -18,13 +17,6 @@ * @since 2025-05-15 */ public interface McpServer { - /** - * Gets MCP server schema. - * - * @return The MCP server schema as a {@link ServerSchema}. - */ - ServerSchema getSchema(); - /** * Gets MCP server tools. * diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java index d1649b7b1..d37de553c 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java @@ -9,7 +9,6 @@ import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolChangedObserver; @@ -57,15 +56,6 @@ public DefaultMcpStreamableServer(ToolExecuteService toolExecuteService, McpSync this.mcpSyncServer = mcpSyncServer; } - @Override - public ServerSchema getSchema() { - ServerSchema.Info info = new ServerSchema.Info("FIT Store MCP Server", "3.6.0-SNAPSHOT"); - ServerSchema.Capabilities.Logging logging = new ServerSchema.Capabilities.Logging(); - ServerSchema.Capabilities.Tools tools = new ServerSchema.Capabilities.Tools(true); - ServerSchema.Capabilities capabilities = new ServerSchema.Capabilities(logging, tools); - return new ServerSchema("2025-06-18", capabilities, info); - } - @Override public List getTools() { return this.mcpSyncServer.listTools().stream() diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 44738a948..ad15e9bd0 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -12,7 +12,6 @@ import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.*; -import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; import modelengine.fit.http.annotation.DeleteMapping; @@ -28,6 +27,7 @@ import modelengine.fit.http.server.HttpClassicServerResponse; import modelengine.fitframework.flowable.Choir; import modelengine.fitframework.flowable.Emitter; +import modelengine.fitframework.inspection.Validation; import modelengine.fitframework.log.Logger; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -59,24 +59,22 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer * Flag indicating whether DELETE requests are disallowed on the endpoint. */ private final boolean disallowDelete; - private final ObjectMapper objectMapper; + private final McpTransportContextExtractor contextExtractor; + private KeepAliveScheduler keepAliveScheduler; private McpStreamableServerSession.Factory sessionFactory; /** * Map of active client sessions, keyed by mcp-session-id. */ - private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - - private final McpTransportContextExtractor contextExtractor; + private final Map sessions = new ConcurrentHashMap<>(); /** * Flag indicating if the transport is shutting down. */ private volatile boolean isClosing = false; - private KeepAliveScheduler keepAliveScheduler; /** * Constructs a new FitMcpStreamableServerTransportProvider instance, @@ -93,8 +91,8 @@ private FitMcpStreamableServerTransportProvider(ObjectMapper objectMapper, boolean disallowDelete, McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); - Assert.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); + Validation.notNull(objectMapper, "ObjectMapper must not be null"); + Validation.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); this.objectMapper = objectMapper; this.disallowDelete = disallowDelete; @@ -522,16 +520,13 @@ else if (map.containsKey("result") || map.containsKey("error")) { * send messages concurrently. */ private class FitStreamableMcpSessionTransport implements McpStreamableServerTransport { - private final String sessionId; - + private final HttpClassicServerResponse response; private final Emitter emitter; private final ReentrantLock lock = new ReentrantLock(); private volatile boolean closed = false; - - private final HttpClassicServerResponse response; /** * Creates a new session transport with the specified ID and SSE builder. @@ -676,14 +671,10 @@ public static Builder builder() { * Builder for creating instances of {@link FitMcpStreamableServerTransportProvider}. */ public static class Builder { - private ObjectMapper objectMapper; - private boolean disallowDelete = false; - private McpTransportContextExtractor contextExtractor = ( HttpClassicServerRequest) -> McpTransportContext.EMPTY; - private Duration keepAliveInterval; /** @@ -694,7 +685,7 @@ public static class Builder { * @throws IllegalArgumentException if objectMapper is null */ public Builder objectMapper(ObjectMapper objectMapper) { - Assert.notNull(objectMapper, "ObjectMapper must not be null"); + Validation.notNull(objectMapper, "ObjectMapper must not be null"); this.objectMapper = objectMapper; return this; } @@ -722,7 +713,7 @@ public Builder disallowDelete(boolean disallowDelete) { * @throws IllegalArgumentException if contextExtractor is null */ public Builder contextExtractor(McpTransportContextExtractor contextExtractor) { - Assert.notNull(contextExtractor, "contextExtractor must not be null"); + Validation.notNull(contextExtractor, "contextExtractor must not be null"); this.contextExtractor = contextExtractor; return this; } @@ -748,7 +739,7 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { * @throws IllegalStateException if required parameters are not set */ public FitMcpStreamableServerTransportProvider build() { - Assert.notNull(this.objectMapper, "ObjectMapper must be set"); + Validation.notNull(this.objectMapper, "ObjectMapper must be set"); return new FitMcpStreamableServerTransportProvider(this.objectMapper, this.disallowDelete, this.contextExtractor, this.keepAliveInterval); diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java index 4c5d1cdb0..b6c89d474 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java @@ -7,7 +7,6 @@ package modelengine.fel.tool.mcp.server.support; import io.modelcontextprotocol.server.McpSyncServer; -import modelengine.fel.tool.mcp.entity.ServerSchema; import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.service.ToolExecuteService; @@ -23,7 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; /** * Unit test for {@link DefaultMcpStreamableServer}. @@ -54,29 +55,6 @@ void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { } } - @Nested - @DisplayName("getInfo Method Tests") - class GivenGetInfo { - @Test - @DisplayName("Should return expected server information") - void returnExpectedServerInfo() { - McpServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); - ServerSchema info = server.getSchema(); - - assertThat(info).returns("2025-06-18", ServerSchema::protocolVersion); - - ServerSchema.Capabilities capabilities = info.capabilities(); - assertThat(capabilities).isNotNull(); - - ServerSchema.Capabilities.Tools toolsCapability = capabilities.tools(); - assertThat(toolsCapability).returns(true, ServerSchema.Capabilities.Tools::listChanged); - - ServerSchema.Info serverInfo = info.serverInfo(); - assertThat(serverInfo).returns("FIT Store MCP Server", ServerSchema.Info::name) - .returns("3.6.0-SNAPSHOT", ServerSchema.Info::version); - } - } - @Nested @DisplayName("registerToolsChangedObserver and Notification Tests") class GivenRegisterAndNotify { From b415e0db7a82ebff330105515e31550482b2ddd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 30 Oct 2025 15:03:26 +0800 Subject: [PATCH 28/37] =?UTF-8?q?=E6=A0=B9=E6=8D=AE0.14.1=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=8CObjectMapper=E6=9B=B4=E6=96=B0=E4=B8=BAMcpJso?= =?UTF-8?q?nMapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/McpServerConfig.java | 3 +- .../support/DefaultMcpStreamableServer.java | 1 - ...tMcpStreamableServerTransportProvider.java | 62 +++++++------------ .../DefaultMcpStreamableServerTest.java | 4 +- 4 files changed, 29 insertions(+), 41 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java index 2d39377b5..079561eb7 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java @@ -7,6 +7,7 @@ package modelengine.fel.tool.mcp.server; import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; @@ -27,7 +28,7 @@ public class McpServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { return FitMcpStreamableServerTransportProvider.builder() - .objectMapper(new ObjectMapper()) + .jsonMapper(McpJsonMapper.getDefault()) .build(); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java index d37de553c..65181da91 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java @@ -34,7 +34,6 @@ * with MCP Server Bean {@link McpSyncServer}. * * @author 季聿阶 - * @author 黄可欣 * @since 2025-05-15 */ @Component diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index ad15e9bd0..0b062bb12 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -6,9 +6,8 @@ package modelengine.fel.tool.mcp.server.transport; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.*; @@ -33,7 +32,6 @@ import reactor.core.publisher.Mono; import java.io.IOException; -import java.lang.reflect.Type; import java.time.Duration; import java.util.List; import java.util.Map; @@ -59,7 +57,7 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer * Flag indicating whether DELETE requests are disallowed on the endpoint. */ private final boolean disallowDelete; - private final ObjectMapper objectMapper; + private final McpJsonMapper jsonMapper; private final McpTransportContextExtractor contextExtractor; private KeepAliveScheduler keepAliveScheduler; @@ -80,21 +78,21 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer * Constructs a new FitMcpStreamableServerTransportProvider instance, * for {@link FitMcpStreamableServerTransportProvider.Builder}. * - * @param objectMapper The ObjectMapper to use for JSON serialization/deserialization + * @param jsonMapper The jsonMapper to use for JSON serialization/deserialization * of messages. * @param disallowDelete Whether to disallow DELETE requests on the endpoint. * @param contextExtractor The context extractor to fill in a {@link McpTransportContext}. * @param keepAliveInterval The interval for sending keep-alive messages to clients. * @throws IllegalArgumentException if any parameter is null */ - private FitMcpStreamableServerTransportProvider(ObjectMapper objectMapper, + private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, boolean disallowDelete, McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { - Validation.notNull(objectMapper, "ObjectMapper must not be null"); + Validation.notNull(jsonMapper, "jsonMapper must not be null"); Validation.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); - this.objectMapper = objectMapper; + this.jsonMapper = jsonMapper; this.disallowDelete = disallowDelete; this.contextExtractor = contextExtractor; @@ -321,15 +319,14 @@ public Object handlePost(HttpClassicServerRequest request, } McpTransportContext transportContext = this.contextExtractor.extract(request); try { - McpSchema.JSONRPCMessage message = this.deserializeJsonRpcMessage(requestBody); // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { logger.info("[POST] Handling initialize method, with receiving message: {}", requestBody.toString()); - McpSchema.InitializeRequest initializeRequest = objectMapper.convertValue(jsonrpcRequest.params(), - new TypeReference() { + McpSchema.InitializeRequest initializeRequest = jsonMapper.convertValue(jsonrpcRequest.params(), + new TypeRef() { }); McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory .startSession(initializeRequest); @@ -487,7 +484,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe } /** - * Handles DELETE requests for session deletion. + * deserialize Map to JsonRpcMessage * * @param map the map of JSON-RPC message * @return The corresponding {@link McpSchema.JSONRPCMessage} class @@ -495,16 +492,14 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe */ public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) throws IOException { - - // Determine message type based on specific JSON structure if (map.containsKey("method") && map.containsKey("id")) { - return objectMapper.convertValue(map, McpSchema.JSONRPCRequest.class); + return jsonMapper.convertValue(map, McpSchema.JSONRPCRequest.class); } else if (map.containsKey("method") && !map.containsKey("id")) { - return objectMapper.convertValue(map, McpSchema.JSONRPCNotification.class); + return jsonMapper.convertValue(map, McpSchema.JSONRPCNotification.class); } else if (map.containsKey("result") || map.containsKey("error")) { - return objectMapper.convertValue(map, McpSchema.JSONRPCResponse.class); + return jsonMapper.convertValue(map, McpSchema.JSONRPCResponse.class); } throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + map.toString()); @@ -583,7 +578,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId return; } - String jsonText = objectMapper.writeValueAsString(message); + String jsonText = jsonMapper.writeValueAsString(message); TextEvent textEvent = TextEvent.custom() .id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); @@ -607,7 +602,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId } /** - * Converts data from one type to another using the configured ObjectMapper. + * Converts data from one type to another using the configured jsonMapper. * * @param data The source data object to convert * @param typeRef The target type reference @@ -616,14 +611,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId */ @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - // Convert TypeRef to TypeReference for ObjectMapper compatibility - TypeReference typeReference = new TypeReference() { - @Override - public Type getType() { - return typeRef.getType(); - } - }; - return objectMapper.convertValue(data, typeReference); + return jsonMapper.convertValue(data, typeRef); } /** @@ -671,22 +659,22 @@ public static Builder builder() { * Builder for creating instances of {@link FitMcpStreamableServerTransportProvider}. */ public static class Builder { - private ObjectMapper objectMapper; + private McpJsonMapper jsonMapper; private boolean disallowDelete = false; private McpTransportContextExtractor contextExtractor = ( HttpClassicServerRequest) -> McpTransportContext.EMPTY; private Duration keepAliveInterval; /** - * Sets the ObjectMapper to use for JSON serialization/deserialization of MCP messages. + * Sets the jsonMapper to use for JSON serialization/deserialization of MCP messages. * - * @param objectMapper The ObjectMapper instance. Must not be null. + * @param jsonMapper The jsonMapper instance. Must not be null. * @return this builder instance - * @throws IllegalArgumentException if objectMapper is null + * @throws IllegalArgumentException if jsonMapper is null */ - public Builder objectMapper(ObjectMapper objectMapper) { - Validation.notNull(objectMapper, "ObjectMapper must not be null"); - this.objectMapper = objectMapper; + public Builder jsonMapper(McpJsonMapper jsonMapper) { + Validation.notNull(jsonMapper, "jsonMapper must not be null"); + this.jsonMapper = jsonMapper; return this; } @@ -739,12 +727,10 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { * @throws IllegalStateException if required parameters are not set */ public FitMcpStreamableServerTransportProvider build() { - Validation.notNull(this.objectMapper, "ObjectMapper must be set"); + Validation.notNull(this.jsonMapper, "jsonMapper must be set"); - return new FitMcpStreamableServerTransportProvider(this.objectMapper, this.disallowDelete, + return new FitMcpStreamableServerTransportProvider(this.jsonMapper, this.disallowDelete, this.contextExtractor, this.keepAliveInterval); } - } - } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java index b6c89d474..71339acdc 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java @@ -9,6 +9,7 @@ import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.mcp.server.McpServer; +import modelengine.fel.tool.mcp.server.McpServerConfig; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; import org.junit.jupiter.api.BeforeEach; @@ -40,7 +41,8 @@ public class DefaultMcpStreamableServerTest { @BeforeEach void setup() { this.toolExecuteService = mock(ToolExecuteService.class); - this.mcpSyncServer = mock(McpSyncServer.class); + McpServerConfig config = new McpServerConfig(); + this.mcpSyncServer = config.mcpSyncServer(config.fitMcpStreamableServerTransportProvider(), 10); } @Nested From b9296cb51ff8d4703e842a69f3dacb66f93e502e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 30 Oct 2025 15:33:43 +0800 Subject: [PATCH 29/37] =?UTF-8?q?=E4=BF=AE=E6=94=B9onToolAdded()=E6=8A=9B?= =?UTF-8?q?=E5=87=BA=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/server/support/DefaultMcpStreamableServer.java | 10 +++------- .../FitMcpStreamableServerTransportProvider.java | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java index 65181da91..5199af28c 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java @@ -112,13 +112,9 @@ public void onToolAdded(String name, String description, Map par }) .build(); - try { - this.mcpSyncServer.addTool(toolSpecification); - log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); - this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); - } catch (Exception e) { - log.error("Failed to add tool to MCP server. [toolName={}]", name, e); - } + this.mcpSyncServer.addTool(toolSpecification); + log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); + this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } @Override diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 0b062bb12..da50d3314 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -516,8 +516,8 @@ else if (map.containsKey("result") || map.containsKey("error")) { */ private class FitStreamableMcpSessionTransport implements McpStreamableServerTransport { private final String sessionId; - private final HttpClassicServerResponse response; private final Emitter emitter; + private final HttpClassicServerResponse response; private final ReentrantLock lock = new ReentrantLock(); From d8e52be6a5a08fbc08aad20935aa9c700a89d544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Tue, 4 Nov 2025 10:04:11 +0800 Subject: [PATCH 30/37] =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=8A=A0onToo?= =?UTF-8?q?lAdded()=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/McpServerConfig.java | 13 +- .../support/DefaultMcpStreamableServer.java | 108 ++++++---- ...tMcpStreamableServerTransportProvider.java | 202 +++++++++--------- .../DefaultMcpStreamableServerTest.java | 17 +- 4 files changed, 180 insertions(+), 160 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java index 079561eb7..880bc4599 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java @@ -6,8 +6,8 @@ package modelengine.fel.tool.mcp.server; -import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; @@ -27,20 +27,15 @@ public class McpServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { - return FitMcpStreamableServerTransportProvider.builder() - .jsonMapper(McpJsonMapper.getDefault()) - .build(); + return FitMcpStreamableServerTransportProvider.builder().jsonMapper(McpJsonMapper.getDefault()).build(); } @Bean public McpSyncServer mcpSyncServer(FitMcpStreamableServerTransportProvider transportProvider, @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { - return io.modelcontextprotocol.server.McpServer.sync(transportProvider) + return McpServer.sync(transportProvider) .serverInfo("FIT Store MCP Server", "3.6.0-SNAPSHOT") - .capabilities(McpSchema.ServerCapabilities.builder() - .tools(true) - .logging() - .build()) + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build()) .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java index 5199af28c..f3de70277 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java @@ -6,6 +6,11 @@ package modelengine.fel.tool.mcp.server.support; +import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; +import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; +import static modelengine.fel.tool.info.schema.ToolsSchema.REQUIRED; +import static modelengine.fitframework.inspection.Validation.notNull; + import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; @@ -24,11 +29,6 @@ import java.util.Map; import java.util.stream.Collectors; -import static modelengine.fel.tool.info.schema.PluginSchema.TYPE; -import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; -import static modelengine.fel.tool.info.schema.ToolsSchema.REQUIRED; -import static modelengine.fitframework.inspection.Validation.notNull; - /** * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} * with MCP Server Bean {@link McpSyncServer}. @@ -57,9 +57,7 @@ public DefaultMcpStreamableServer(ToolExecuteService toolExecuteService, McpSync @Override public List getTools() { - return this.mcpSyncServer.listTools().stream() - .map(this::convertToFelTool) - .collect(Collectors.toList()); + return this.mcpSyncServer.listTools().stream().map(this::convertToFelTool).collect(Collectors.toList()); } @Override @@ -87,30 +85,9 @@ public void onToolAdded(String name, String description, Map par log.warn("Invalid parameter schema. [toolName={}]", name); return; } - @SuppressWarnings("unchecked") - McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema((String) parameters.get(TYPE), - (Map) parameters.get(PROPERTIES), (List) parameters.get(REQUIRED), - null, null,null); - McpServerFeatures.SyncToolSpecification toolSpecification = McpServerFeatures.SyncToolSpecification.builder() - .tool(McpSchema.Tool.builder() - .name(name) - .description(description) - .inputSchema(inputSchema) - .build()) - .callHandler((exchange, request) -> { - try { - Map args = request.arguments(); - String result = this.toolExecuteService.execute(name, args); - return new McpSchema.CallToolResult(result, false); - } catch (IllegalArgumentException e) { - log.warn("Invalid arguments for tool execution. [toolName={}, error={}]", name, e.getMessage()); - return new McpSchema.CallToolResult("Error: Invalid arguments - " + e.getMessage(), true); - } catch (Exception e) { - log.error("Failed to execute tool. [toolName={}]", name, e); - return new McpSchema.CallToolResult("Error: Tool execution failed - " + e.getMessage(), true); - } - }) - .build(); + + McpServerFeatures.SyncToolSpecification toolSpecification = + createToolSpecification(name, description, parameters); this.mcpSyncServer.addTool(toolSpecification); log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); @@ -128,6 +105,63 @@ public void onToolRemoved(String name) { this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } + /** + * Creates a tool specification for the MCP server. + *

+ * This method constructs a {@link McpServerFeatures.SyncToolSpecification} that includes: + *

    + *
  • Tool metadata (name, description, input schema)
  • + *
  • Call handler that executes the tool and handles exceptions
  • + *
+ * + * @param name The name of the tool. + * @param description The description of the tool. + * @param parameters The parameter schema containing type, properties, and required fields. + * @return A fully configured {@link McpServerFeatures.SyncToolSpecification}. + */ + private McpServerFeatures.SyncToolSpecification createToolSpecification(String name, String description, + Map parameters) { + @SuppressWarnings("unchecked") McpSchema.JsonSchema inputSchema = + new McpSchema.JsonSchema((String) parameters.get(TYPE), + (Map) parameters.get(PROPERTIES), + (List) parameters.get(REQUIRED), + null, + null, + null); + + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder().name(name).description(description).inputSchema(inputSchema).build()) + .callHandler((exchange, request) -> executeToolWithErrorHandling(name, request)) + .build(); + } + + /** + * Executes a tool and handles any exceptions that may occur. + *

+ * This method handles two types of exceptions: + *

    + *
  • {@link IllegalArgumentException}: Invalid tool arguments (logged as warning)
  • + *
  • {@link Exception}: Any other execution failure (logged as error)
  • + *
+ * + * @param toolName The name of the tool to execute. + * @param request The tool call request containing arguments. + * @return A {@link McpSchema.CallToolResult} with the execution result or error message. + */ + private McpSchema.CallToolResult executeToolWithErrorHandling(String toolName, McpSchema.CallToolRequest request) { + try { + Map args = request.arguments(); + String result = this.toolExecuteService.execute(toolName, args); + return new McpSchema.CallToolResult(result, false); + } catch (IllegalArgumentException e) { + log.warn("Invalid arguments for tool execution. [toolName={}, error={}]", toolName, e.getMessage()); + return new McpSchema.CallToolResult("Error: Invalid arguments - " + e.getMessage(), true); + } catch (Exception e) { + log.error("Failed to execute tool. [toolName={}]", toolName, e); + return new McpSchema.CallToolResult("Error: Tool execution failed - " + e.getMessage(), true); + } + } + /** * Converts an MCP SDK Tool to a FEL Tool entity. * @@ -138,7 +172,7 @@ private Tool convertToFelTool(McpSchema.Tool mcpTool) { Tool tool = new Tool(); tool.setName(mcpTool.name()); tool.setDescription(mcpTool.description()); - + // Convert JsonSchema to Map McpSchema.JsonSchema inputSchema = mcpTool.inputSchema(); Map schemaMap = new HashMap<>(); @@ -150,7 +184,7 @@ private Tool convertToFelTool(McpSchema.Tool mcpTool) { schemaMap.put(REQUIRED, inputSchema.required()); } tool.setInputSchema(schemaMap); - + return tool; } @@ -178,10 +212,6 @@ private boolean isValidParameterSchema(Map parameters) { if (!(reqs instanceof List reqsList)) { return false; } - if (reqsList.stream().anyMatch(v -> !(v instanceof String))) { - return false; - } - - return true; + return reqsList.stream().allMatch(v -> v instanceof String); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index da50d3314..74061ee0b 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -10,7 +10,13 @@ import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; -import io.modelcontextprotocol.spec.*; +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.spec.ProtocolVersions; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; import modelengine.fit.http.annotation.DeleteMapping; @@ -49,9 +55,6 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer private static final Logger logger = Logger.get(FitMcpStreamableServerTransportProvider.class); private static final String MESSAGE_ENDPOINT = "/mcp/streamable"; - public static final String MESSAGE_EVENT_TYPE = "message"; - public static final String ENDPOINT_EVENT_TYPE = "endpoint"; - public static final String DEFAULT_BASE_URL = ""; /** * Flag indicating whether DELETE requests are disallowed on the endpoint. @@ -73,7 +76,6 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer */ private volatile boolean isClosing = false; - /** * Constructs a new FitMcpStreamableServerTransportProvider instance, * for {@link FitMcpStreamableServerTransportProvider.Builder}. @@ -85,10 +87,8 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer * @param keepAliveInterval The interval for sending keep-alive messages to clients. * @throws IllegalArgumentException if any parameter is null */ - private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, - boolean disallowDelete, - McpTransportContextExtractor contextExtractor, - Duration keepAliveInterval) { + private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, boolean disallowDelete, + McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { Validation.notNull(jsonMapper, "jsonMapper must not be null"); Validation.notNull(contextExtractor, "McpTransportContextExtractor must not be null"); @@ -97,8 +97,9 @@ private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, this.contextExtractor = contextExtractor; if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler - .builder(() -> (isClosing) ? Flux.empty() : Flux.fromIterable(this.sessions.values())) + this.keepAliveScheduler = KeepAliveScheduler.builder(() -> (isClosing) + ? Flux.empty() + : Flux.fromIterable(this.sessions.values())) .initialDelay(keepAliveInterval) .interval(keepAliveInterval) .build(); @@ -109,7 +110,9 @@ private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, @Override public List protocolVersions() { - return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18); + return List.of(ProtocolVersions.MCP_2024_11_05, + ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18); } @Override @@ -139,8 +142,7 @@ public Mono notifyClients(String method, Object params) { this.sessions.values().parallelStream().forEach(session -> { try { session.sendNotification(method, params).block(); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); } }); @@ -161,8 +163,7 @@ public Mono closeGracefully() { this.sessions.values().parallelStream().forEach(session -> { try { session.closeGracefully().block(); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to close session {}: {}", session.getId(), e.getMessage()); } }); @@ -176,7 +177,6 @@ public Mono closeGracefully() { }); } - /** * Set up the listening SSE connections and message replay. * @@ -209,9 +209,10 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) - .message("Session not found: " + sessionId) - .build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: " + sessionId) + .build()); } logger.info("[GET] Handling GET request for session: {}", sessionId); @@ -220,8 +221,8 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo return Choir.create(emitter -> { // TODO emitter.onTimeout() logger.info() - FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport( - sessionId, emitter, response); + FitStreamableMcpSessionTransport sessionTransport = + new FitStreamableMcpSessionTransport(sessionId, emitter, response); // Check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { @@ -237,23 +238,20 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo sessionTransport.sendMessage(message) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to replay message: {}", e.getMessage()); emitter.fail(e); } }); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to replay messages: {}", e.getMessage()); emitter.fail(e); } - } - else { + } else { // Establish new listening stream logger.info("[GET] Receiving Get request to establish new SSE for session: {}", sessionId); - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = session - .listeningStream(sessionTransport); + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = + session.listeningStream(sessionTransport); emitter.observe(new Emitter.Observer() { @Override @@ -283,8 +281,7 @@ public void onFailed(Exception cause) { }); } }); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return null; @@ -300,9 +297,8 @@ public void onFailed(Exception cause) { * @return Return the HTTP response body {@link Entity} or a {@link Choir}{@code <}{@link TextEvent}{@code >} object */ @PostMapping(path = MESSAGE_ENDPOINT) - public Object handlePost(HttpClassicServerRequest request, - HttpClassicServerResponse response, - @RequestBody Map requestBody) { + public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response, + @RequestBody Map requestBody) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); return Entity.createText(response, "Server is shutting down"); @@ -322,14 +318,13 @@ public Object handlePost(HttpClassicServerRequest request, McpSchema.JSONRPCMessage message = this.deserializeJsonRpcMessage(requestBody); // Handle initialization request - if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest - && jsonrpcRequest.method().equals(McpSchema.METHOD_INITIALIZE)) { + if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method() + .equals(McpSchema.METHOD_INITIALIZE)) { logger.info("[POST] Handling initialize method, with receiving message: {}", requestBody.toString()); - McpSchema.InitializeRequest initializeRequest = jsonMapper.convertValue(jsonrpcRequest.params(), - new TypeRef() { - }); - McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory - .startSession(initializeRequest); + McpSchema.InitializeRequest initializeRequest = + jsonMapper.convertValue(jsonrpcRequest.params(), new TypeRef() {}); + McpStreamableServerSession.McpStreamableServerSessionInit init = + this.sessionFactory.startSession(initializeRequest); this.sessions.put(init.session().getId(), init.session()); try { @@ -337,28 +332,26 @@ public Object handlePost(HttpClassicServerRequest request, response.statusCode(HttpResponseStatus.OK.statusCode()); response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); - logger.info( - "[POST] Sending initialize message via HTTP response to session {}", - init.session().getId() - ); + logger.info("[POST] Sending initialize message via HTTP response to session {}", + init.session().getId()); return Entity.createObject(response, - new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); - } - catch (Exception e) { + new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, + jsonrpcRequest.id(), + initResult, + null)); + } catch (Exception e) { logger.error("Failed to initialize session: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR) - .message(e.getMessage()) - .build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); } } // Handle other messages that require a session if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) - .message("Session ID missing") - .build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Session ID missing").build()); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); @@ -367,9 +360,10 @@ public Object handlePost(HttpClassicServerRequest request, if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) - .message("Session not found: " + sessionId) - .build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: " + sessionId) + .build()); } if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { @@ -378,15 +372,13 @@ public Object handlePost(HttpClassicServerRequest request, .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); return null; - } - else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { session.accept(jsonrpcNotification) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); return null; - } - else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { // For streaming responses, we need to return SSE return Choir.create(emitter -> { // TODO emitter.onTimeout() logger.info @@ -407,7 +399,8 @@ public void onFailed(Exception e) { } }); - FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport(sessionId, emitter, response); + FitStreamableMcpSessionTransport sessionTransport = + new FitStreamableMcpSessionTransport(sessionId, emitter, response); try { session.responseStream(jsonrpcRequest, sessionTransport) @@ -416,22 +409,23 @@ public void onFailed(Exception e) { } catch (Exception e) { logger.error("Failed to handle request stream: {}", e.getMessage()); emitter.fail(e); - }}); - } - else { + } + }); + } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); } - } - catch (IllegalArgumentException | IOException e) { + } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format").build()); - } - catch (Exception e) { + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format").build()); + } catch (Exception e) { logger.error("Error handling message: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); } } @@ -467,7 +461,10 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe logger.info("[DELETE] Receiving delete request from session: {}", sessionId); if (session == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS).message("Session not found: " + sessionId).build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: " + sessionId) + .build()); } try { @@ -475,11 +472,11 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe this.sessions.remove(sessionId); response.statusCode(HttpResponseStatus.OK.statusCode()); return null; - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); } } @@ -490,15 +487,12 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe * @return The corresponding {@link McpSchema.JSONRPCMessage} class * @throws IOException when cannot deserialize JSONRPCMessage */ - public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) - throws IOException { + public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) throws IOException { if (map.containsKey("method") && map.containsKey("id")) { return jsonMapper.convertValue(map, McpSchema.JSONRPCRequest.class); - } - else if (map.containsKey("method") && !map.containsKey("id")) { + } else if (map.containsKey("method") && !map.containsKey("id")) { return jsonMapper.convertValue(map, McpSchema.JSONRPCNotification.class); - } - else if (map.containsKey("result") || map.containsKey("error")) { + } else if (map.containsKey("result") || map.containsKey("error")) { return jsonMapper.convertValue(map, McpSchema.JSONRPCResponse.class); } @@ -530,7 +524,8 @@ private class FitStreamableMcpSessionTransport implements McpStreamableServerTra * @param emitter The emitter for sending events * @param response The HTTP response for checking connection status */ - FitStreamableMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { + FitStreamableMcpSessionTransport(String sessionId, Emitter emitter, + HttpClassicServerResponse response) { this.sessionId = sessionId; this.emitter = emitter; this.response = response; @@ -570,32 +565,31 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId logger.info("Session {} was closed during message send attempt", this.sessionId); return; } - + // Check if connection is still active before sending if (!this.response.isActive()) { - logger.warn("[SSE] Connection inactive detected while sending message for session: {}", this.sessionId); + logger.warn("[SSE] Connection inactive detected while sending message for session: {}", + this.sessionId); this.close(); return; } String jsonText = jsonMapper.writeValueAsString(message); - TextEvent textEvent = TextEvent.custom() - .id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); + TextEvent textEvent = + TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); logger.info("[SSE] Sending message to session {}: {}", this.sessionId, jsonText); - } - catch (Exception e) { + } catch (Exception e) { logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); try { this.emitter.fail(e); - } - catch (Exception errorException) { - logger.error("Failed to send error to SSE builder for session {}: {}", this.sessionId, + } catch (Exception errorException) { + logger.error("Failed to send error to SSE builder for session {}: {}", + this.sessionId, errorException.getMessage()); } - } - finally { + } finally { this.lock.unlock(); } }); @@ -606,8 +600,8 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId * * @param data The source data object to convert * @param typeRef The target type reference - * @return The converted object of type T * @param The target type + * @return The converted object of type T */ @Override public T unmarshalFrom(Object data, TypeRef typeRef) { @@ -640,11 +634,9 @@ public void close() { this.emitter.complete(); logger.info("[SSE] Closed SSE builder successfully for session {}", sessionId); - } - catch (Exception e) { + } catch (Exception e) { logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage()); - } - finally { + } finally { this.lock.unlock(); } } @@ -661,8 +653,8 @@ public static Builder builder() { public static class Builder { private McpJsonMapper jsonMapper; private boolean disallowDelete = false; - private McpTransportContextExtractor contextExtractor = ( - HttpClassicServerRequest) -> McpTransportContext.EMPTY; + private McpTransportContextExtractor contextExtractor = + (HttpClassicServerRequest) -> McpTransportContext.EMPTY; private Duration keepAliveInterval; /** @@ -729,8 +721,10 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { public FitMcpStreamableServerTransportProvider build() { Validation.notNull(this.jsonMapper, "jsonMapper must be set"); - return new FitMcpStreamableServerTransportProvider(this.jsonMapper, this.disallowDelete, - this.contextExtractor, this.keepAliveInterval); + return new FitMcpStreamableServerTransportProvider(this.jsonMapper, + this.disallowDelete, + this.contextExtractor, + this.keepAliveInterval); } } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java index 71339acdc..0e411778f 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java @@ -6,12 +6,19 @@ package modelengine.fel.tool.mcp.server.support; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + import io.modelcontextprotocol.server.McpSyncServer; import modelengine.fel.tool.mcp.entity.Tool; import modelengine.fel.tool.mcp.server.McpServer; import modelengine.fel.tool.mcp.server.McpServerConfig; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -21,12 +28,6 @@ import java.util.List; import java.util.Map; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowableOfType; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - /** * Unit test for {@link DefaultMcpStreamableServer}. * @@ -51,8 +52,8 @@ class GivenConstructor { @Test @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { - IllegalArgumentException exception = - catchThrowableOfType(IllegalArgumentException.class, () -> new DefaultMcpStreamableServer(null, mcpSyncServer)); + IllegalArgumentException exception = catchThrowableOfType(IllegalArgumentException.class, + () -> new DefaultMcpStreamableServer(null, mcpSyncServer)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } From d10b0aa06e30bf6e55cf899aa6642002d227e0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Tue, 4 Nov 2025 16:55:48 +0800 Subject: [PATCH 31/37] =?UTF-8?q?transport=E7=B1=BB=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...64\346\212\244\346\226\207\346\241\243.md" | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 "framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" diff --git "a/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" "b/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" new file mode 100644 index 000000000..155630bb5 --- /dev/null +++ "b/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" @@ -0,0 +1,394 @@ +# FitMcpStreamableServerTransportProvider类维护文档 + +## 文档概述 + +本文档用于记录 `FitMcpStreamableServerTransportProvider` 类的设计、实现细节以及维护更新指南。该类是基于 MCP SDK 中的 `WebMvcStreamableServerTransportProvider` 类改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 + +**原始参考类**: MCP SDK 中的 `WebMvcStreamableServerTransportProvider` (或 `HttpServletStreamableServerTransportProvider`) + +**作者**: 黄可欣 +**创建时间**: 2025-11-04 + +--- + +## 类的作用和职责 + +`FitMcpStreamableServerTransportProvider` 是 MCP 服务端传输层的核心实现类,负责: + +1. **HTTP 端点处理**: 处理 GET、POST、DELETE 请求,实现 MCP 协议的 HTTP 传输层 +2. **会话管理**: 管理客户端会话的生命周期(创建、维护、销毁) +3. **SSE 通信**: 通过 Server-Sent Events (SSE) 实现服务端到客户端的实时消息推送 +4. **消息序列化**: 处理 JSON-RPC 消息的序列化和反序列化 +5. **连接保活**: 支持可选的 Keep-Alive 机制 +6. **优雅关闭**: 支持服务的优雅关闭和资源清理 + +--- + +## 类结构概览 + +### 主要成员变量 + +| 变量名 | 类型 | 来源 | 说明 | +|--------|------|------|------| +| `MESSAGE_ENDPOINT` | `String` | SDK 原始 | 消息端点路径 `/mcp/streamable` | +| `disallowDelete` | `boolean` | SDK 原始 | 是否禁用 DELETE 请求 | +| `jsonMapper` | `McpJsonMapper` | SDK 原始 | JSON 序列化器 | +| `contextExtractor` | `McpTransportContextExtractor` | **FIT 改造** | 上下文提取器(泛型参数改为 FIT 的 Request 类型) | +| `keepAliveScheduler` | `KeepAliveScheduler` | SDK 原始 | Keep-Alive 调度器 | +| `sessionFactory` | `McpStreamableServerSession.Factory` | SDK 原始 | 会话工厂 | +| `sessions` | `Map` | SDK 原始 | 活跃会话映射表 | +| `isClosing` | `volatile boolean` | SDK 原始 | 关闭标志 | + +### 主要方法 + +| 方法名 | 来源 | 说明 | +|--------|------|------| +| `protocolVersions()` | SDK 原始 | 返回支持的 MCP 协议版本 | +| `setSessionFactory()` | SDK 原始 | 设置会话工厂 | +| `notifyClients()` | SDK 原始 | 广播通知到所有客户端 | +| `closeGracefully()` | SDK 原始 | 优雅关闭传输层 | +| `handleGet()` | **FIT 改造** | 处理 GET 请求(SSE 连接) | +| `handlePost()` | **FIT 改造** | 处理 POST 请求(JSON-RPC 消息) | +| `handleDelete()` | **FIT 改造** | 处理 DELETE 请求(会话删除) | +| `deserializeJsonRpcMessage()` | **FIT 创建** | 反序列化 JSON-RPC 消息 | + +### 内部类 + +| 类名 | 来源 | 说明 | +|------|------|------| +| `FitStreamableMcpSessionTransport` | **FIT 改造** | 用于SSE 会话`sendMessage()`传输实现 | +| `Builder` | SDK 原始 | 构建器模式 | + +--- + +## SDK 原始逻辑 + +以下是从 MCP SDK 的 `WebMvcStreamableServerTransportProvider` 类保留的原始逻辑: + +### 1. 会话管理核心逻辑 +```java +private final Map sessions = new ConcurrentHashMap<>(); +``` +- 使用 `ConcurrentHashMap` 存储活跃会话 +- 会话以 `mcp-session-id` 作为键 + +### 2. 会话工厂设置 +```java +public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; +} +``` +- 由外部设置会话工厂,用于创建新会话 + +### 3. 客户端通知 +```java +public Mono notifyClients(String method, Object params) { + // ... 广播逻辑 +} +``` +- 向所有活跃会话并行发送通知 +- 使用 `parallelStream()` 提高效率 +- 单个会话失败不影响其他会话 + +### 4. HTTP 端点处理核心流程 + +#### a. GET 请求处理流程(原始逻辑) + +1. 检查 Accept 头是否包含 `text/event-stream` +2. 验证 `mcp-session-id` 头是否存在 +3. 查找对应的会话 +4. 检查是否是重放请求(`Last-Event-ID` 头) +5. 建立 SSE 连接或重放消息 + +#### b. POST 请求处理流程(原始逻辑) + +1. 检查 Accept 头 +2. 反序列化 JSON-RPC 消息 +3. 特殊处理 `initialize` 请求(创建新会话) +4. 处理其他请求(需要已存在的会话) +5. 根据消息类型(Response/Notification/Request)分别处理 + +#### c. DELETE 请求处理流程(原始逻辑) + +1. 检查是否禁用 DELETE +2. 验证 `mcp-session-id` 头 +3. 查找并删除会话 + +### 5. 关闭逻辑 +```java +public Mono closeGracefully() { + this.isClosing = true; + // ... 关闭所有会话 + // ... 关闭 keep-alive 调度器 +} +``` +- 设置关闭标志 +- 关闭所有活跃会话 +- 清理资源 + +### 6. Keep-Alive 机制 +```java +if (keepAliveInterval != null) { + this.keepAliveScheduler = KeepAliveScheduler.builder(...) + .initialDelay(keepAliveInterval) + .interval(keepAliveInterval) + .build(); + this.keepAliveScheduler.start(); +} +``` +- 支持可选的 Keep-Alive 调度 + + + +## FIT 框架新增/改造逻辑 + +以下是为适配 FIT 框架而新增或改造的部分: + +### 1. HTTP 类替换(重要改造) + +**原始 SDK(Spring MVC)**: + +```java +@GetMapping("/mcp/streamable") +public ResponseEntity handleGet(HttpServletRequest request, HttpServletResponse response) + +@PostMapping("/mcp/streamable") +public ResponseEntity handlePost(HttpServletRequest request, @RequestBody Map body) + +@DeleteMapping("/mcp/streamable") +public ResponseEntity handleDelete(HttpServletRequest request) +``` + +**FIT 框架改造后**: +```java +@GetMapping(path = MESSAGE_ENDPOINT) +public Object handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) + +@PostMapping(path = MESSAGE_ENDPOINT) +public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response, + @RequestBody Map requestBody) + +@DeleteMapping(path = MESSAGE_ENDPOINT) +public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) +``` + +**关键变化**: +- 使用 FIT 的注解:`@GetMapping`, `@PostMapping`, `@DeleteMapping` +- 请求/响应对象类型变更: + - `HttpServletRequest` → `HttpClassicServerRequest` + - `HttpServletResponse` → `HttpClassicServerResponse` +- 返回类型改为通用的 `Object`,支持多种返回形式 + +### 2. SSE 实现改造(核心改造) + +**原始 SDK (Spring MVC)**: +```java +SseEmitter sseEmitter = new SseEmitter(); +sseEmitter.send(SseEmitter.event() + .id(messageId) + .name("message") + .data(jsonText)); +sseEmitter.complete(); +``` + +**FIT 框架改造**: +```java +// 使用 Choir 和 Emitter 实现 SSE +Choir.create(emitter -> { + // 创建 TextEvent 并发送 + TextEvent textEvent = TextEvent.custom() + .id(sessionId) + .event(Event.MESSAGE.code()) + .data(jsonText) + .build(); + emitter.emit(textEvent); + + // 监听 Emitter 的生命周期 + emitter.observe(new Emitter.Observer() { + @Override + public void onEmittedData(TextEvent data) { + // 数据发送完成 + } + + @Override + public void onCompleted() { + // SSE 流正常结束 + listeningStream.close(); + } + + @Override + public void onFailed(Exception cause) { + // SSE 流异常结束 + listeningStream.close(); + } + }); +}); +``` + +**关键变化**: +- 使用 `Choir` 返回事件流 +- 使用 `Emitter` 替代 `SseEmitter` 的发送方法 +- 使用 `Emitter.Observer` 监听 SSE 生命周期事件 + +### 3. HTTP 响应处理改造 + +**FIT 特有的响应方式**: + +#### 返回纯文本 + +```java +response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); +return Entity.createText(response, "Session ID required in mcp-session-id header"); +``` + +#### 返回 JSON 对象 + +```java +response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); +return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: " + sessionId) + .build()); +``` + +#### 返回 SSE 流(重要改造) + +```java +return Choir.create(emitter -> { + // 使用 FIT 的 Emitter 发送 SSE 事件 + emitter.emit(textEvent); + emitter.complete(); + emitter.fail(exception); +}); +``` + +### 4. HTTP 头处理改造 + +**FIT 框架的 Headers API**: +```java +// 获取 Header +String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); +boolean hasSessionId = request.headers().contains(HttpHeaders.MCP_SESSION_ID); +String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); + +// 设置 Header +response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); +response.headers().set(HttpHeaders.MCP_SESSION_ID, sessionId); + +// 设置状态码 +response.statusCode(HttpResponseStatus.OK.statusCode()); +``` + +**变化**: + +- 使用 `request.headers().first(name).orElse(default)` 获取单个 Header +- 使用 `request.headers().contains(name)` 检查 Header 是否存在 +- 使用 FIT 的 `MessageHeaderNames` 和 `MimeType` 常量 +- 使用 `HttpResponseStatus` 枚举设置状态码 + +### 5. 内部类 Transport 实现 + +`FitStreamableMcpSessionTransport` 类的核心职责是发送SSE事件: + +- `sendmessage()`方法通过`Emitter` 发送SSE消息到客户端 +- 保存了当前会话的事件的`Emitter`,负责close时关闭`Emitter` + +- SSE的`Emitter`感知不到GET连接是否断开,因此在`sendmessage()`发送前检查GET连接是否活跃 + +```java +// 在发送消息前检查连接是否仍然活跃 +if (!this.response.isActive()) { + logger.warn("[SSE] Connection inactive detected while sending message for session: {}", + this.sessionId); + this.close(); + return; +} +``` + +### 6. JSON-RPC 消息反序列化 + +```java +public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) { + // 根据字段判断消息类型 + if (map.containsKey("method") && map.containsKey("id")) { + return jsonMapper.convertValue(map, McpSchema.JSONRPCRequest.class); + } else if (map.containsKey("method") && !map.containsKey("id")) { + return jsonMapper.convertValue(map, McpSchema.JSONRPCNotification.class); + } else if (map.containsKey("result") || map.containsKey("error")) { + return jsonMapper.convertValue(map, McpSchema.JSONRPCResponse.class); + } + throw new IllegalArgumentException(...); +} +``` + +- 智能识别 JSON-RPC 消息类型 + + + +## 代码结构对照表 + +| 功能模块 | 改造程度 | SDK 原始实现 | FIT 框架实现 | +|---------|---------|-------------|-------------| +| SSE 实现 | **重大改造** | `SseEmitter` | `Choir` + `Emitter` | +| HTTP 请求对象 | **重大改造** | `HttpServletRequest` | `HttpClassicServerRequest` | +| HTTP 响应对象 | **重大改造** | `HttpServletResponse` | `HttpClassicServerResponse` | +| HTTP返回类型 | **重大改造** | `ResponseEntity` | `Object` (`Entity`或者`Choir`) | +| Get连接检测 | 新增 | 无 | `response.isActive()` | +| 验证工具 | 新增 | 无或其他 | FIT Validation | +| 日志系统 | 轻微改造 | SLF4J | FIT Logger | +| Builder 模式 | 轻微改造 | 原始逻辑 | 类型参数调整 | +| HTTP 注解 | 无变化 | `@GetMapping` (Spring) | `@GetMapping` (FIT) | +| 接口实现 | 无变化 | `McpStreamableServerTransportProvider` | 相同 | +| 会话管理 | 无变化 | 原始逻辑 | 相同 | +| 消息序列化 | 无变化 | 原始逻辑 | 相同 | +| Keep-Alive | 无变化 | 原始逻辑 | 相同 | + + + +## 参考资源 + +### MCP 协议文档 +- MCP 协议规范:[https://spec.modelcontextprotocol.io/](https://spec.modelcontextprotocol.io/) +- MCP SDK GitHub: [https://github.com/modelcontextprotocol/](https://github.com/modelcontextprotocol/) + +### FIT 框架文档 +- FIT HTTP 模块文档:`docs/framework/fit/java/user-guide-book/04. Web MVC 能力.md` +- FIT 流式功能文档:`docs/framework/fit/java/user-guide-book/10. 流式功能.md` +- FIT 日志文档:`docs/framework/fit/java/user-guide-book/08. 日志.md` + +### 相关类文档 +- `Event` 枚举定义:`modelengine.fel.tool.mcp.entity.Event` +- MCP Server 工具其他实现:`framework/fel/java/plugins/tool-mcp-server/` + +--- + +## 附录:快速定位指南 + +### 查找某个功能的实现位置 + +| 功能 | 方法/类 | 行号范围 | +|------|--------|---------| +| 协议版本声明 | `protocolVersions()` | 112-116 | +| 客户端广播 | `notifyClients()` | 133-150 | +| 优雅关闭 | `closeGracefully()` | 158-178 | +| GET 请求处理 | `handleGet()` | 188-289 | +| POST 请求处理 | `handlePost()` | 300-430 | +| DELETE 请求处理 | `handleDelete()` | 440-481 | +| 消息反序列化 | `deserializeJsonRpcMessage()` | 490-500 | +| SSE 传输实现 | `FitStreamableMcpSessionTransport` | 511-644 | +| 构建器 | `Builder` | 653-729 | + +### 查找某个 FIT 改造点 + +| 改造内容 | 位置 | +|---------|------| +| HTTP 注解 | 187, 299, 439 行 | +| Entity 响应 | 191, 197, 212, 304, 311, 等 | +| Choir SSE | 221, 383 行 | +| Emitter 观察者 | 256-281, 385-400 行 | +| 连接状态检测 | 570-575 行 | +| FIT Logger | 55, 全文多处 | +| FIT Validation | 92-93, 668, 696, 722 行 | + + + From b019e6268f4fb0e918406c40abf3b4d03073ad84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Tue, 4 Nov 2025 17:16:11 +0800 Subject: [PATCH 32/37] =?UTF-8?q?=E4=BF=AE=E6=94=B9md=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...73\264\346\212\244\346\226\207\346\241\243.md" | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git "a/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" "b/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" index 155630bb5..8cf1bded7 100644 --- "a/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" +++ "b/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" @@ -195,13 +195,14 @@ sseEmitter.complete(); ```java // 使用 Choir 和 Emitter 实现 SSE Choir.create(emitter -> { - // 创建 TextEvent 并发送 - TextEvent textEvent = TextEvent.custom() - .id(sessionId) - .event(Event.MESSAGE.code()) - .data(jsonText) - .build(); - emitter.emit(textEvent); + // 创建sessionTransport类,用于调用emitter发送消息 + FitStreamableMcpSessionTransport sessionTransport = + new FitStreamableMcpSessionTransport(sessionId, emitter, response); + + // session的逻辑是SDK原有的,里面会调用sessionTransport发送事件流 + session.responseStream(jsonrpcRequest, sessionTransport) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); // 监听 Emitter 的生命周期 emitter.observe(new Emitter.Observer() { From 3fd19f5d9de0a9c10d5ff476211bd265aced9ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 5 Nov 2025 11:31:30 +0800 Subject: [PATCH 33/37] =?UTF-8?q?=E6=9B=B4=E6=96=B0MD=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/plugins/tool-mcp-server/README.md | 201 ++++++++---------- 1 file changed, 90 insertions(+), 111 deletions(-) rename "framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" => framework/fel/java/plugins/tool-mcp-server/README.md (60%) diff --git "a/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" b/framework/fel/java/plugins/tool-mcp-server/README.md similarity index 60% rename from "framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" rename to framework/fel/java/plugins/tool-mcp-server/README.md index 8cf1bded7..feda5128c 100644 --- "a/framework/fel/java/plugins/tool-mcp-server/\346\234\215\345\212\241\345\231\250transport\347\233\270\345\205\263\347\261\273\347\273\264\346\212\244\346\226\207\346\241\243.md" +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -2,11 +2,12 @@ ## 文档概述 -本文档用于记录 `FitMcpStreamableServerTransportProvider` 类的设计、实现细节以及维护更新指南。该类是基于 MCP SDK 中的 `WebMvcStreamableServerTransportProvider` 类改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 +本文档用于记录 `FitMcpStreamableServerTransportProvider` 类的设计、实现细节以及维护更新指南。该类是基于 MCP SDK 中的 +`WebMvcStreamableServerTransportProvider` 类改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 -**原始参考类**: MCP SDK 中的 `WebMvcStreamableServerTransportProvider` (或 `HttpServletStreamableServerTransportProvider`) +**原始参考类**: MCP SDK 中的 `WebMvcStreamableServerTransportProvider` (或 +`HttpServletStreamableServerTransportProvider`) -**作者**: 黄可欣 **创建时间**: 2025-11-04 --- @@ -28,36 +29,36 @@ ### 主要成员变量 -| 变量名 | 类型 | 来源 | 说明 | -|--------|------|------|------| -| `MESSAGE_ENDPOINT` | `String` | SDK 原始 | 消息端点路径 `/mcp/streamable` | -| `disallowDelete` | `boolean` | SDK 原始 | 是否禁用 DELETE 请求 | -| `jsonMapper` | `McpJsonMapper` | SDK 原始 | JSON 序列化器 | -| `contextExtractor` | `McpTransportContextExtractor` | **FIT 改造** | 上下文提取器(泛型参数改为 FIT 的 Request 类型) | -| `keepAliveScheduler` | `KeepAliveScheduler` | SDK 原始 | Keep-Alive 调度器 | -| `sessionFactory` | `McpStreamableServerSession.Factory` | SDK 原始 | 会话工厂 | -| `sessions` | `Map` | SDK 原始 | 活跃会话映射表 | -| `isClosing` | `volatile boolean` | SDK 原始 | 关闭标志 | +| 变量名 | 类型 | 来源 | 说明 | +|----------------------|----------------------------------------------------------|------------|---------------------------------| +| `MESSAGE_ENDPOINT` | `String` | SDK 原始 | 消息端点路径 `/mcp/streamable` | +| `disallowDelete` | `boolean` | SDK 原始 | 是否禁用 DELETE 请求 | +| `jsonMapper` | `McpJsonMapper` | SDK 原始 | JSON 序列化器 | +| `contextExtractor` | `McpTransportContextExtractor` | **FIT 改造** | 上下文提取器(泛型参数改为 FIT 的 Request 类型) | +| `keepAliveScheduler` | `KeepAliveScheduler` | SDK 原始 | Keep-Alive 调度器 | +| `sessionFactory` | `McpStreamableServerSession.Factory` | SDK 原始 | 会话工厂 | +| `sessions` | `Map` | SDK 原始 | 活跃会话映射表 | +| `isClosing` | `volatile boolean` | SDK 原始 | 关闭标志 | ### 主要方法 -| 方法名 | 来源 | 说明 | -|--------|------|------| -| `protocolVersions()` | SDK 原始 | 返回支持的 MCP 协议版本 | -| `setSessionFactory()` | SDK 原始 | 设置会话工厂 | -| `notifyClients()` | SDK 原始 | 广播通知到所有客户端 | -| `closeGracefully()` | SDK 原始 | 优雅关闭传输层 | -| `handleGet()` | **FIT 改造** | 处理 GET 请求(SSE 连接) | -| `handlePost()` | **FIT 改造** | 处理 POST 请求(JSON-RPC 消息) | -| `handleDelete()` | **FIT 改造** | 处理 DELETE 请求(会话删除) | -| `deserializeJsonRpcMessage()` | **FIT 创建** | 反序列化 JSON-RPC 消息 | +| 方法名 | 来源 | 说明 | +|-------------------------------|------------|-------------------------| +| `protocolVersions()` | SDK 原始 | 返回支持的 MCP 协议版本 | +| `setSessionFactory()` | SDK 原始 | 设置会话工厂 | +| `notifyClients()` | SDK 原始 | 广播通知到所有客户端 | +| `closeGracefully()` | SDK 原始 | 优雅关闭传输层 | +| `handleGet()` | **FIT 改造** | 处理 GET 请求(SSE 连接) | +| `handlePost()` | **FIT 改造** | 处理 POST 请求(JSON-RPC 消息) | +| `handleDelete()` | **FIT 改造** | 处理 DELETE 请求(会话删除) | +| `deserializeJsonRpcMessage()` | **FIT 创建** | 反序列化 JSON-RPC 消息 | ### 内部类 -| 类名 | 来源 | 说明 | -|------|------|------| +| 类名 | 来源 | 说明 | +|------------------------------------|------------|-----------------------------| | `FitStreamableMcpSessionTransport` | **FIT 改造** | 用于SSE 会话`sendMessage()`传输实现 | -| `Builder` | SDK 原始 | 构建器模式 | +| `Builder` | SDK 原始 | 构建器模式 | --- @@ -66,26 +67,32 @@ 以下是从 MCP SDK 的 `WebMvcStreamableServerTransportProvider` 类保留的原始逻辑: ### 1. 会话管理核心逻辑 + ```java private final Map sessions = new ConcurrentHashMap<>(); ``` + - 使用 `ConcurrentHashMap` 存储活跃会话 - 会话以 `mcp-session-id` 作为键 ### 2. 会话工厂设置 + ```java public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { this.sessionFactory = sessionFactory; } ``` + - 由外部设置会话工厂,用于创建新会话 ### 3. 客户端通知 + ```java public Mono notifyClients(String method, Object params) { // ... 广播逻辑 } ``` + - 向所有活跃会话并行发送通知 - 使用 `parallelStream()` 提高效率 - 单个会话失败不影响其他会话 @@ -115,6 +122,7 @@ public Mono notifyClients(String method, Object params) { 3. 查找并删除会话 ### 5. 关闭逻辑 + ```java public Mono closeGracefully() { this.isClosing = true; @@ -122,23 +130,24 @@ public Mono closeGracefully() { // ... 关闭 keep-alive 调度器 } ``` + - 设置关闭标志 - 关闭所有活跃会话 - 清理资源 ### 6. Keep-Alive 机制 + ```java -if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler.builder(...) +if(keepAliveInterval != null){ + this.keepAliveScheduler =KeepAliveScheduler.builder(...) .initialDelay(keepAliveInterval) .interval(keepAliveInterval) .build(); this.keepAliveScheduler.start(); } ``` -- 支持可选的 Keep-Alive 调度 - +- 支持可选的 Keep-Alive 调度 ## FIT 框架新增/改造逻辑 @@ -149,6 +158,7 @@ if (keepAliveInterval != null) { **原始 SDK(Spring MVC)**: ```java + @GetMapping("/mcp/streamable") public ResponseEntity handleGet(HttpServletRequest request, HttpServletResponse response) @@ -160,7 +170,9 @@ public ResponseEntity handleDelete(HttpServletRequest request) ``` **FIT 框架改造后**: + ```java + @GetMapping(path = MESSAGE_ENDPOINT) public Object handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) @@ -173,6 +185,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe ``` **关键变化**: + - 使用 FIT 的注解:`@GetMapping`, `@PostMapping`, `@DeleteMapping` - 请求/响应对象类型变更: - `HttpServletRequest` → `HttpClassicServerRequest` @@ -182,6 +195,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe ### 2. SSE 实现改造(核心改造) **原始 SDK (Spring MVC)**: + ```java SseEmitter sseEmitter = new SseEmitter(); sseEmitter.send(SseEmitter.event() @@ -192,41 +206,43 @@ sseEmitter.complete(); ``` **FIT 框架改造**: + ```java // 使用 Choir 和 Emitter 实现 SSE Choir.create(emitter -> { // 创建sessionTransport类,用于调用emitter发送消息 FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport(sessionId, emitter, response); - + // session的逻辑是SDK原有的,里面会调用sessionTransport发送事件流 session.responseStream(jsonrpcRequest, sessionTransport) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + // 监听 Emitter 的生命周期 emitter.observe(new Emitter.Observer() { - @Override - public void onEmittedData(TextEvent data) { - // 数据发送完成 - } + @Override + public void onEmittedData(TextEvent data) { + // 数据发送完成 + } - @Override - public void onCompleted() { - // SSE 流正常结束 - listeningStream.close(); - } + @Override + public void onCompleted() { + // SSE 流正常结束 + listeningStream.close(); + } - @Override - public void onFailed(Exception cause) { - // SSE 流异常结束 - listeningStream.close(); - } + @Override + public void onFailed(Exception cause) { + // SSE 流异常结束 + listeningStream.close(); + } }); }); ``` **关键变化**: + - 使用 `Choir` 返回事件流 - 使用 `Emitter` 替代 `SseEmitter` 的发送方法 - 使用 `Emitter.Observer` 监听 SSE 生命周期事件 @@ -246,26 +262,24 @@ return Entity.createText(response, "Session ID required in mcp-session-id header ```java response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); -return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) - .message("Session not found: " + sessionId) +return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) + .message("Session not found: "+sessionId) .build()); ``` #### 返回 SSE 流(重要改造) ```java -return Choir.create(emitter -> { - // 使用 FIT 的 Emitter 发送 SSE 事件 +return Choir. create(emitter ->{ + // emitter封装在sessionTransport中,被session调用 emitter.emit(textEvent); - emitter.complete(); - emitter.fail(exception); }); ``` ### 4. HTTP 头处理改造 **FIT 框架的 Headers API**: + ```java // 获取 Header String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); @@ -273,7 +287,7 @@ boolean hasSessionId = request.headers().contains(HttpHeaders.MCP_SESSION_ID); String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); // 设置 Header -response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); +response.headers().set("Content-Type",MimeType.APPLICATION_JSON.value()); response.headers().set(HttpHeaders.MCP_SESSION_ID, sessionId); // 设置状态码 @@ -298,9 +312,9 @@ response.statusCode(HttpResponseStatus.OK.statusCode()); ```java // 在发送消息前检查连接是否仍然活跃 -if (!this.response.isActive()) { +if(!this.response.isActive()){ logger.warn("[SSE] Connection inactive detected while sending message for session: {}", - this.sessionId); + this.sessionId); this.close(); return; } @@ -324,72 +338,37 @@ public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map ma - 智能识别 JSON-RPC 消息类型 - - ## 代码结构对照表 -| 功能模块 | 改造程度 | SDK 原始实现 | FIT 框架实现 | -|---------|---------|-------------|-------------| -| SSE 实现 | **重大改造** | `SseEmitter` | `Choir` + `Emitter` | -| HTTP 请求对象 | **重大改造** | `HttpServletRequest` | `HttpClassicServerRequest` | -| HTTP 响应对象 | **重大改造** | `HttpServletResponse` | `HttpClassicServerResponse` | -| HTTP返回类型 | **重大改造** | `ResponseEntity` | `Object` (`Entity`或者`Choir`) | -| Get连接检测 | 新增 | 无 | `response.isActive()` | -| 验证工具 | 新增 | 无或其他 | FIT Validation | -| 日志系统 | 轻微改造 | SLF4J | FIT Logger | -| Builder 模式 | 轻微改造 | 原始逻辑 | 类型参数调整 | -| HTTP 注解 | 无变化 | `@GetMapping` (Spring) | `@GetMapping` (FIT) | -| 接口实现 | 无变化 | `McpStreamableServerTransportProvider` | 相同 | -| 会话管理 | 无变化 | 原始逻辑 | 相同 | -| 消息序列化 | 无变化 | 原始逻辑 | 相同 | -| Keep-Alive | 无变化 | 原始逻辑 | 相同 | - - +| 功能模块 | 改造程度 | SDK 原始实现 | FIT 框架实现 | +|------------|----------|----------------------------------------|--------------------------------| +| SSE 实现 | **重大改造** | `SseEmitter` | `Choir` + `Emitter` | +| HTTP 请求对象 | **重大改造** | `HttpServletRequest` | `HttpClassicServerRequest` | +| HTTP 响应对象 | **重大改造** | `HttpServletResponse` | `HttpClassicServerResponse` | +| HTTP返回类型 | **重大改造** | `ResponseEntity` | `Object` (`Entity`或者`Choir`) | +| Get连接检测 | 新增 | 无 | `response.isActive()` | +| 验证工具 | 新增 | 无或其他 | FIT Validation | +| 日志系统 | 轻微改造 | SLF4J | FIT Logger | +| Builder 模式 | 轻微改造 | 原始逻辑 | 类型参数调整 | +| HTTP 注解 | 无变化 | `@GetMapping` (Spring) | `@GetMapping` (FIT) | +| 接口实现 | 无变化 | `McpStreamableServerTransportProvider` | 相同 | +| 会话管理 | 无变化 | 原始逻辑 | 相同 | +| 消息序列化 | 无变化 | 原始逻辑 | 相同 | +| Keep-Alive | 无变化 | 原始逻辑 | 相同 | ## 参考资源 ### MCP 协议文档 + - MCP 协议规范:[https://spec.modelcontextprotocol.io/](https://spec.modelcontextprotocol.io/) - MCP SDK GitHub: [https://github.com/modelcontextprotocol/](https://github.com/modelcontextprotocol/) ### FIT 框架文档 + - FIT HTTP 模块文档:`docs/framework/fit/java/user-guide-book/04. Web MVC 能力.md` - FIT 流式功能文档:`docs/framework/fit/java/user-guide-book/10. 流式功能.md` - FIT 日志文档:`docs/framework/fit/java/user-guide-book/08. 日志.md` ### 相关类文档 - `Event` 枚举定义:`modelengine.fel.tool.mcp.entity.Event` -- MCP Server 工具其他实现:`framework/fel/java/plugins/tool-mcp-server/` - ---- - -## 附录:快速定位指南 - -### 查找某个功能的实现位置 - -| 功能 | 方法/类 | 行号范围 | -|------|--------|---------| -| 协议版本声明 | `protocolVersions()` | 112-116 | -| 客户端广播 | `notifyClients()` | 133-150 | -| 优雅关闭 | `closeGracefully()` | 158-178 | -| GET 请求处理 | `handleGet()` | 188-289 | -| POST 请求处理 | `handlePost()` | 300-430 | -| DELETE 请求处理 | `handleDelete()` | 440-481 | -| 消息反序列化 | `deserializeJsonRpcMessage()` | 490-500 | -| SSE 传输实现 | `FitStreamableMcpSessionTransport` | 511-644 | -| 构建器 | `Builder` | 653-729 | - -### 查找某个 FIT 改造点 - -| 改造内容 | 位置 | -|---------|------| -| HTTP 注解 | 187, 299, 439 行 | -| Entity 响应 | 191, 197, 212, 304, 311, 等 | -| Choir SSE | 221, 383 行 | -| Emitter 观察者 | 256-281, 385-400 行 | -| 连接状态检测 | 570-575 行 | -| FIT Logger | 55, 全文多处 | -| FIT Validation | 92-93, 668, 696, 722 行 | - - - +- MCP Server 工具其他实现:`framework/fel/java/plugins/tool-mcp-server/` \ No newline at end of file From 99517c46b40eddeed7b653927e4bdbdd95c1c616 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 5 Nov 2025 18:12:06 +0800 Subject: [PATCH 34/37] =?UTF-8?q?transportProvider=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tMcpStreamableServerTransportProvider.java | 484 +++++++++++------- 1 file changed, 291 insertions(+), 193 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 74061ee0b..5fc69195a 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -22,7 +22,6 @@ import modelengine.fit.http.annotation.DeleteMapping; import modelengine.fit.http.annotation.GetMapping; import modelengine.fit.http.annotation.PostMapping; -import modelengine.fit.http.annotation.RequestBody; import modelengine.fit.http.entity.Entity; import modelengine.fit.http.entity.TextEvent; import modelengine.fit.http.protocol.HttpResponseStatus; @@ -38,6 +37,7 @@ import reactor.core.publisher.Mono; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Map; @@ -191,94 +191,31 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo return Entity.createText(response, "Server is shutting down"); } - String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); - if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value())) { - response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM"); + Object headerError = validateGetAcceptHeaders(request, response); + if (headerError != null) { + return headerError; } - McpTransportContext transportContext = this.contextExtractor.extract(request); - - if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { - response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createText(response, "Session ID required in mcp-session-id header"); + // Get session ID and session + Object sessionError = validateRequestSessionId(request, response); + if (sessionError != null) { + return sessionError; } - String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); McpStreamableServerSession session = this.sessions.get(sessionId); - - if (session == null) { - response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) - .message("Session not found: " + sessionId) - .build()); - } - logger.info("[GET] Handling GET request for session: {}", sessionId); + McpTransportContext transportContext = this.contextExtractor.extract(request); try { return Choir.create(emitter -> { - // TODO emitter.onTimeout() logger.info() - FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport(sessionId, emitter, response); - // Check if this is a replay request + // Handle building SSE, and check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { - String lastId = request.headers().first(HttpHeaders.LAST_EVENT_ID).orElse("0"); - - logger.info("[GET] Receiving replay request from session: {}", sessionId); - try { - session.replay(lastId) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .toIterable() - .forEach(message -> { - try { - sessionTransport.sendMessage(message) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - } catch (Exception e) { - logger.error("Failed to replay message: {}", e.getMessage()); - emitter.fail(e); - } - }); - } catch (Exception e) { - logger.error("Failed to replay messages: {}", e.getMessage()); - emitter.fail(e); - } + handleReplaySseRequest(request, transportContext, sessionId, session, sessionTransport, emitter); } else { - // Establish new listening stream - logger.info("[GET] Receiving Get request to establish new SSE for session: {}", sessionId); - McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = - session.listeningStream(sessionTransport); - - emitter.observe(new Emitter.Observer() { - @Override - public void onEmittedData(TextEvent data) { - // No action needed - } - - @Override - public void onCompleted() { - logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); - try { - listeningStream.close(); - } catch (Exception e) { - logger.warn("[SSE] Error closing listeningStream on complete: {}", e.getMessage()); - } - } - - @Override - public void onFailed(Exception cause) { - logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, cause.getMessage()); - try { - listeningStream.close(); - } catch (Exception e) { - logger.warn("[SSE] Error closing listeningStream on failure: {}", e.getMessage()); - } - } - }); + handleEstablishSseRequest(sessionId, session, sessionTransport, emitter); } }); } catch (Exception e) { @@ -293,124 +230,47 @@ public void onFailed(Exception cause) { * * @param request The incoming server request containing the JSON-RPC message * @param response The HTTP response - * @param requestBody the map of JSON-RPC message * @return Return the HTTP response body {@link Entity} or a {@link Choir}{@code <}{@link TextEvent}{@code >} object */ @PostMapping(path = MESSAGE_ENDPOINT) - public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response, - @RequestBody Map requestBody) { + public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); return Entity.createText(response, "Server is shutting down"); } - - String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); - if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value()) - || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { - response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) - .message("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON") - .build()); + Object headerError = validatePostAcceptHeaders(request, response); + if (headerError != null) { + return headerError; } + McpTransportContext transportContext = this.contextExtractor.extract(request); try { - McpSchema.JSONRPCMessage message = this.deserializeJsonRpcMessage(requestBody); + String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, requestBody); // Handle initialization request if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method() .equals(McpSchema.METHOD_INITIALIZE)) { - logger.info("[POST] Handling initialize method, with receiving message: {}", requestBody.toString()); - McpSchema.InitializeRequest initializeRequest = - jsonMapper.convertValue(jsonrpcRequest.params(), new TypeRef() {}); - McpStreamableServerSession.McpStreamableServerSessionInit init = - this.sessionFactory.startSession(initializeRequest); - this.sessions.put(init.session().getId(), init.session()); - - try { - McpSchema.InitializeResult initResult = init.initResult().block(); - response.statusCode(HttpResponseStatus.OK.statusCode()); - response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); - response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); - logger.info("[POST] Sending initialize message via HTTP response to session {}", - init.session().getId()); - return Entity.createObject(response, - new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, - jsonrpcRequest.id(), - initResult, - null)); - } catch (Exception e) { - logger.error("Failed to initialize session: {}", e.getMessage()); - response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); - } + logger.info("[POST] Handling initialize method, with receiving message: {}", requestBody); + return handleInitializeRequest(request, response, jsonrpcRequest); } - // Handle other messages that require a session - if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { - response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST).message("Session ID missing").build()); + // Get session ID and session + Object sessionError = validateRequestSessionId(request, response); + if (sessionError != null) { + return sessionError; } - String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); McpStreamableServerSession session = this.sessions.get(sessionId); - logger.info("[POST] Receiving message from session {}: {}", sessionId, requestBody.toString()); - - if (session == null) { - response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); - return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) - .message("Session not found: " + sessionId) - .build()); - } + logger.info("[POST] Receiving message from session {}: {}", sessionId, requestBody); + // Handle JSONRPCMessage if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - session.accept(jsonrpcResponse) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); - return null; + return handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - session.accept(jsonrpcNotification) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); - return null; + return handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - // For streaming responses, we need to return SSE - return Choir.create(emitter -> { - // TODO emitter.onTimeout() logger.info - emitter.observe(new Emitter.Observer() { - @Override - public void onEmittedData(TextEvent data) { - // No action needed - } - - @Override - public void onCompleted() { - logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); - } - - @Override - public void onFailed(Exception e) { - logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, e.getMessage()); - } - }); - - FitStreamableMcpSessionTransport sessionTransport = - new FitStreamableMcpSessionTransport(sessionId, emitter, response); - - try { - session.responseStream(jsonrpcRequest, sessionTransport) - .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) - .block(); - } catch (Exception e) { - logger.error("Failed to handle request stream: {}", e.getMessage()); - emitter.fail(e); - } - }); + return handleJsonRpcRequest(jsonrpcRequest, session, sessionId, transportContext, response); } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, @@ -442,38 +302,210 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); return Entity.createText(response, "Server is shutting down"); } - if (this.disallowDelete) { response.statusCode(HttpResponseStatus.METHOD_NOT_ALLOWED.statusCode()); return null; } + // Get session ID and session + Object sessionError = validateRequestSessionId(request, response); + if (sessionError != null) { + return sessionError; + } + String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); + McpStreamableServerSession session = this.sessions.get(sessionId); + logger.info("[DELETE] Receiving delete request from session: {}", sessionId); + McpTransportContext transportContext = this.contextExtractor.extract(request); + try { + session.delete().contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); + this.sessions.remove(sessionId); + response.statusCode(HttpResponseStatus.OK.statusCode()); + return null; + } catch (Exception e) { + logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); + } + } + /** + * Validates the Accept header for SSE (Server-Sent Events) connections in GET requests. + * Checks if the request contains the required {@code text/event-stream} content type. + * + * @param request The incoming {@link HttpClassicServerRequest} + * @param response The {@link HttpClassicServerResponse} to set status code if validation fails + * @return An error {@link Entity} if validation fails, {@code null} if validation succeeds + */ + private Object validateGetAcceptHeaders(HttpClassicServerRequest request, HttpClassicServerResponse response) { + String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); + if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value())) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + return Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM"); + } + return null; + } + + /** + * Validates the Accept headers for POST requests. + * Checks if the request contains both {@code text/event-stream} and {@code application/json} content types, + * as POST requests may return either SSE streams or JSON responses. + * + * @param request The incoming {@link HttpClassicServerRequest} + * @param response The {@link HttpClassicServerResponse} to set status code if validation fails + * @return An error {@link Entity} with {@link McpError} if validation fails, {@code null} if validation succeeds + */ + private Object validatePostAcceptHeaders(HttpClassicServerRequest request, HttpClassicServerResponse response) { + String acceptHeaders = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); + if (!acceptHeaders.contains(MimeType.TEXT_EVENT_STREAM.value()) + || !acceptHeaders.contains(MimeType.APPLICATION_JSON.value())) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST) + .message("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON") + .build()); + } + return null; + } + + /** + * Validates the MCP session ID in the request headers and verifies the session exists. + * This method checks both the presence of the {@code mcp-session-id} header and + * the existence of the corresponding session in the active sessions map. + * + * @param request The incoming {@link HttpClassicServerRequest} containing the session ID header + * @param response The {@link HttpClassicServerResponse} to set status code if validation fails + * @return An error {@link Entity} if validation fails (either missing session ID or session not found), + * {@code null} if validation succeeds + */ + private Object validateRequestSessionId(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return Entity.createText(response, "Session ID required in mcp-session-id header"); } - String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); - McpStreamableServerSession session = this.sessions.get(sessionId); - - logger.info("[DELETE] Receiving delete request from session: {}", sessionId); - if (session == null) { + if (this.sessions.get(sessionId) == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) .message("Session not found: " + sessionId) .build()); } + return null; + } + + /** + * Handles message replay requests for SSE connections. + * Replays previously sent messages starting from the last received event ID, + * allowing clients to recover missed messages after reconnection. + * + * @param request The incoming {@link HttpClassicServerRequest} containing the {@code Last-Event-ID} header + * @param transportContext The {@link McpTransportContext} for request context propagation + * @param sessionId The MCP session identifier + * @param session The {@link McpStreamableServerSession} to replay messages from + * @param sessionTransport The {@link FitStreamableMcpSessionTransport} for sending replayed messages + * @param emitter The SSE {@link Emitter} to send {@link TextEvent} to the client + */ + private void handleReplaySseRequest(HttpClassicServerRequest request, McpTransportContext transportContext, + String sessionId, McpStreamableServerSession session, FitStreamableMcpSessionTransport sessionTransport, + Emitter emitter) { + String lastId = request.headers().first(HttpHeaders.LAST_EVENT_ID).orElse("0"); + logger.info("[GET] Receiving replay request from session: {}", sessionId); try { - session.delete().contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); - this.sessions.remove(sessionId); + session.replay(lastId) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .toIterable() + .forEach(message -> { + try { + sessionTransport.sendMessage(message) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + } catch (Exception e) { + logger.error("Failed to replay message: {}", e.getMessage()); + emitter.fail(e); + } + }); + } catch (Exception e) { + logger.error("Failed to replay messages: {}", e.getMessage()); + emitter.fail(e); + } + } + + /** + * Establishes a new SSE listening stream for real-time message delivery. + * Creates a persistent connection that allows the server to push messages to the client + * as they become available. The stream remains open until explicitly closed or an error occurs. + * + * @param sessionId The MCP session identifier + * @param session The {@link McpStreamableServerSession} to establish the listening stream for + * @param sessionTransport The {@link FitStreamableMcpSessionTransport} for bidirectional communication + * @param emitter The SSE {@link Emitter} to send {@link TextEvent} to the client + */ + private void handleEstablishSseRequest(String sessionId, McpStreamableServerSession session, + FitStreamableMcpSessionTransport sessionTransport, Emitter emitter) { + logger.info("[GET] Receiving Get request to establish new SSE for session: {}", sessionId); + McpStreamableServerSession.McpStreamableServerSessionStream listeningStream = + session.listeningStream(sessionTransport); + + emitter.observe(new Emitter.Observer() { + @Override + public void onEmittedData(TextEvent data) { + // No action needed + } + + @Override + public void onCompleted() { + logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); + try { + listeningStream.close(); + } catch (Exception e) { + logger.warn("[SSE] Error closing listeningStream on complete: {}", e.getMessage()); + } + } + + @Override + public void onFailed(Exception cause) { + logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, cause.getMessage()); + try { + listeningStream.close(); + } catch (Exception e) { + logger.warn("[SSE] Error closing listeningStream on failure: {}", e.getMessage()); + } + } + }); + } + + /** + * Handles MCP session initialization requests. + * Creates a new {@link McpStreamableServerSession} and returns the initialization result + * with the assigned session ID in the response headers. + * + * @param request The incoming {@link HttpClassicServerRequest} + * @param response The {@link HttpClassicServerResponse} to set session ID and initialization result + * @param jsonrpcRequest The {@link McpSchema.JSONRPCRequest} containing {@link McpSchema.InitializeRequest} parameters + * @return An {@link Entity} containing the {@link McpSchema.JSONRPCResponse} with {@link McpSchema.InitializeResult} + * on success, or an error {@link Entity} with {@link McpError} on failure + */ + private Object handleInitializeRequest(HttpClassicServerRequest request, HttpClassicServerResponse response, + McpSchema.JSONRPCRequest jsonrpcRequest) { + McpSchema.InitializeRequest initializeRequest = + jsonMapper.convertValue(jsonrpcRequest.params(), new TypeRef() {}); + McpStreamableServerSession.McpStreamableServerSessionInit init = + this.sessionFactory.startSession(initializeRequest); + this.sessions.put(init.session().getId(), init.session()); + + try { + McpSchema.InitializeResult initResult = init.initResult().block(); response.statusCode(HttpResponseStatus.OK.statusCode()); - return null; + response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); + response.headers().set(HttpHeaders.MCP_SESSION_ID, init.session().getId()); + logger.info("[POST] Sending initialize message via HTTP response to session {}", init.session().getId()); + return Entity.createObject(response, + new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); } catch (Exception e) { - logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); + logger.error("Failed to initialize session: {}", e.getMessage()); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); @@ -481,22 +513,88 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe } /** - * deserialize Map to JsonRpcMessage + * Handles incoming JSON-RPC response messages from clients. + * Accepts the response and delivers it to the corresponding pending request within the session. + * Sets the HTTP response status to {@code 202 Accepted} to acknowledge receipt. * - * @param map the map of JSON-RPC message - * @return The corresponding {@link McpSchema.JSONRPCMessage} class - * @throws IOException when cannot deserialize JSONRPCMessage + * @param jsonrpcResponse The {@link McpSchema.JSONRPCResponse} from the client + * @param session The {@link McpStreamableServerSession} to accept the response + * @param transportContext The {@link McpTransportContext} for request context propagation + * @param response The {@link HttpClassicServerResponse} to set the status code + * @return {@code null} as the response status is set to {@code 202 Accepted} */ - public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) throws IOException { - if (map.containsKey("method") && map.containsKey("id")) { - return jsonMapper.convertValue(map, McpSchema.JSONRPCRequest.class); - } else if (map.containsKey("method") && !map.containsKey("id")) { - return jsonMapper.convertValue(map, McpSchema.JSONRPCNotification.class); - } else if (map.containsKey("result") || map.containsKey("error")) { - return jsonMapper.convertValue(map, McpSchema.JSONRPCResponse.class); - } + private Object handleJsonRpcResponse(McpSchema.JSONRPCResponse jsonrpcResponse, McpStreamableServerSession session, + McpTransportContext transportContext, HttpClassicServerResponse response) { + session.accept(jsonrpcResponse).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); + response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); + return null; + } - throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + map.toString()); + /** + * Handles incoming JSON-RPC notification messages from clients. + * Notifications are one-way messages that do not require a response. + * Sets the HTTP response status to {@code 202 Accepted} to acknowledge receipt. + * + * @param jsonrpcNotification The {@link McpSchema.JSONRPCNotification} from the client + * @param session The {@link McpStreamableServerSession} to accept the notification + * @param transportContext The {@link McpTransportContext} for request context propagation + * @param response The {@link HttpClassicServerResponse} to set the status code + * @return {@code null} as the response status is set to {@code 202 Accepted} + */ + private Object handleJsonRpcNotification(McpSchema.JSONRPCNotification jsonrpcNotification, + McpStreamableServerSession session, McpTransportContext transportContext, + HttpClassicServerResponse response) { + session.accept(jsonrpcNotification) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); + return null; + } + + /** + * Handles incoming JSON-RPC request messages from clients with streaming response support. + * Creates an SSE stream to send the response and any subsequent messages back to the client. + * This allows for real-time, bidirectional communication during request processing. + * + * @param jsonrpcRequest The {@link McpSchema.JSONRPCRequest} from the client + * @param session The {@link McpStreamableServerSession} to process the request + * @param sessionId The MCP session identifier for logging and tracking + * @param transportContext The {@link McpTransportContext} for request context propagation + * @param response The {@link HttpClassicServerResponse} for the SSE stream + * @return A {@link Choir} containing {@link TextEvent} for SSE streaming of the response + */ + private Object handleJsonRpcRequest(McpSchema.JSONRPCRequest jsonrpcRequest, McpStreamableServerSession session, + String sessionId, McpTransportContext transportContext, HttpClassicServerResponse response) { + return Choir.create(emitter -> { + emitter.observe(new Emitter.Observer() { + @Override + public void onEmittedData(TextEvent data) { + // No action needed + } + + @Override + public void onCompleted() { + logger.info("[SSE] Completed SSE emitting for session: {}", sessionId); + } + + @Override + public void onFailed(Exception e) { + logger.warn("[SSE] SSE failed for session: {}, cause: {}", sessionId, e.getMessage()); + } + }); + + FitStreamableMcpSessionTransport sessionTransport = + new FitStreamableMcpSessionTransport(sessionId, emitter, response); + + try { + session.responseStream(jsonrpcRequest, sessionTransport) + .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) + .block(); + } catch (Exception e) { + logger.error("Failed to handle request stream: {}", e.getMessage()); + emitter.fail(e); + } + }); } /** From 22a4a303ced241305dfe1fbb3e8c6ae81cf63dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Wed, 5 Nov 2025 18:31:21 +0800 Subject: [PATCH 35/37] =?UTF-8?q?=E6=9B=B4=E6=96=B0README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/plugins/tool-mcp-server/README.md | 202 +++++++----------- ...tMcpStreamableServerTransportProvider.java | 10 +- 2 files changed, 81 insertions(+), 131 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/README.md b/framework/fel/java/plugins/tool-mcp-server/README.md index feda5128c..79fdad626 100644 --- a/framework/fel/java/plugins/tool-mcp-server/README.md +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -42,16 +42,38 @@ ### 主要方法 -| 方法名 | 来源 | 说明 | -|-------------------------------|------------|-------------------------| -| `protocolVersions()` | SDK 原始 | 返回支持的 MCP 协议版本 | -| `setSessionFactory()` | SDK 原始 | 设置会话工厂 | -| `notifyClients()` | SDK 原始 | 广播通知到所有客户端 | -| `closeGracefully()` | SDK 原始 | 优雅关闭传输层 | -| `handleGet()` | **FIT 改造** | 处理 GET 请求(SSE 连接) | -| `handlePost()` | **FIT 改造** | 处理 POST 请求(JSON-RPC 消息) | -| `handleDelete()` | **FIT 改造** | 处理 DELETE 请求(会话删除) | -| `deserializeJsonRpcMessage()` | **FIT 创建** | 反序列化 JSON-RPC 消息 | +| 方法名 | 来源 | 说明 | +| --------------------- | ------------ | ------------------------------- | +| `protocolVersions()` | SDK 原始 | 返回支持的 MCP 协议版本 | +| `setSessionFactory()` | SDK 原始 | 设置会话工厂 | +| `notifyClients()` | SDK 原始 | 广播通知到所有客户端 | +| `closeGracefully()` | SDK 原始 | 优雅关闭传输层 | +| `handleGet()` | **FIT 改造** | 处理 GET 请求(SSE 连接) | +| `handlePost()` | **FIT 改造** | 处理 POST 请求(JSON-RPC 消息) | +| `handleDelete()` | **FIT 改造** | 处理 DELETE 请求(会话删除) | + +### 重构后的辅助方法 + +为提高代码可读性和可维护性,从原本的 `handleGet()`、`handlePost()`、`handleDelete()` 方法中抽取了以下辅助方法: + +#### 验证请求合法性的方法 + +| 方法名 | 说明 | +|-------------------------------|----------------------------------------------------------| +| `validateGetAcceptHeaders()` | 验证 GET 请求的 Accept 头,确保包含 `text/event-stream` | +| `validatePostAcceptHeaders()` | 验证 POST 请求的 Accept 头,确保包含 `text/event-stream` 和 `application/json` | +| `validateRequestSessionId()` | 验证请求的 `mcp-session-id` 头是否存在,以及对应的会话是否存在 | + +#### 根据请求类型调用处理逻辑的方法 + +| 方法名 | 处理的请求类型 | 说明 | +|--------------------------------|---------|--------------------------------------| +| `handleReplaySseRequest()` | GET | 处理 SSE 消息重放请求,用于断线重连后恢复错过的消息 | +| `handleEstablishSseRequest()` | GET | 处理 SSE 连接建立请求,创建新的持久化 SSE 监听流 | +| `handleInitializeRequest()` | POST | 处理客户端初始化连接请求,创建新的 MCP 会话 | +| `handleJsonRpcResponse()` | POST | 处理 JSON-RPC 响应消息(如 Elicitation 中的客户端响应) | +| `handleJsonRpcNotification()` | POST | 处理 JSON-RPC 通知消息(客户端单向通知) | +| `handleJsonRpcRequest()` | POST | 处理 JSON-RPC 请求消息,返回 SSE 流式响应 | ### 内部类 @@ -97,31 +119,7 @@ public Mono notifyClients(String method, Object params) { - 使用 `parallelStream()` 提高效率 - 单个会话失败不影响其他会话 -### 4. HTTP 端点处理核心流程 - -#### a. GET 请求处理流程(原始逻辑) - -1. 检查 Accept 头是否包含 `text/event-stream` -2. 验证 `mcp-session-id` 头是否存在 -3. 查找对应的会话 -4. 检查是否是重放请求(`Last-Event-ID` 头) -5. 建立 SSE 连接或重放消息 - -#### b. POST 请求处理流程(原始逻辑) - -1. 检查 Accept 头 -2. 反序列化 JSON-RPC 消息 -3. 特殊处理 `initialize` 请求(创建新会话) -4. 处理其他请求(需要已存在的会话) -5. 根据消息类型(Response/Notification/Request)分别处理 - -#### c. DELETE 请求处理流程(原始逻辑) - -1. 检查是否禁用 DELETE -2. 验证 `mcp-session-id` 头 -3. 查找并删除会话 - -### 5. 关闭逻辑 +### 4. 关闭逻辑 ```java public Mono closeGracefully() { @@ -135,63 +133,52 @@ public Mono closeGracefully() { - 关闭所有活跃会话 - 清理资源 -### 6. Keep-Alive 机制 - -```java -if(keepAliveInterval != null){ - this.keepAliveScheduler =KeepAliveScheduler.builder(...) - .initialDelay(keepAliveInterval) - .interval(keepAliveInterval) - .build(); - this.keepAliveScheduler.start(); -} -``` - -- 支持可选的 Keep-Alive 调度 - -## FIT 框架新增/改造逻辑 +## FIT 框架改造核心逻辑 以下是为适配 FIT 框架而新增或改造的部分: -### 1. HTTP 类替换(重要改造) +### 1. HTTP 端点处理核心流程(核心改造) -**原始 SDK(Spring MVC)**: - -```java - -@GetMapping("/mcp/streamable") -public ResponseEntity handleGet(HttpServletRequest request, HttpServletResponse response) - -@PostMapping("/mcp/streamable") -public ResponseEntity handlePost(HttpServletRequest request, @RequestBody Map body) - -@DeleteMapping("/mcp/streamable") -public ResponseEntity handleDelete(HttpServletRequest request) -``` - -**FIT 框架改造后**: - -```java - -@GetMapping(path = MESSAGE_ENDPOINT) -public Object handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) - -@PostMapping(path = MESSAGE_ENDPOINT) -public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response, - @RequestBody Map requestBody) - -@DeleteMapping(path = MESSAGE_ENDPOINT) -public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) -``` - -**关键变化**: - -- 使用 FIT 的注解:`@GetMapping`, `@PostMapping`, `@DeleteMapping` - 请求/响应对象类型变更: - `HttpServletRequest` → `HttpClassicServerRequest` - `HttpServletResponse` → `HttpClassicServerResponse` - 返回类型改为通用的 `Object`,支持多种返回形式 +#### a. GET 请求处理流程 + +1. 检查服务器是否正在关闭 +2. **调用 `validateGetAcceptHeaders()`** - 验证 Accept 头是否包含 `text/event-stream` +3. **调用 `validateRequestSessionId()`** - 验证 `mcp-session-id` 头是否存在及对应会话是否存在 +4. 提取 `transportContext` 上下文 +5. 获取会话 ID 和会话对象 +6. 检查是否是重放请求(`Last-Event-ID` 头): + - 如果是,**调用 `handleReplaySseRequest()`** - 重放错过的消息 + - 如果否,**调用 `handleEstablishSseRequest()`** - 建立新的 SSE 监听流 + +#### b. POST 请求处理流程 + +1. 检查服务器是否正在关闭 +2. **调用 `validatePostAcceptHeaders()`** - 验证 Accept 头包含 `text/event-stream` 和 `application/json` +3. 提取 `transportContext` 上下文 +4. 反序列化 JSON-RPC 消息 +5. 判断是否为初始化请求(`initialize` 方法): + - 如果是,**调用 `handleInitializeRequest()`** - 创建新会话并返回初始化结果 +6. **调用 `validateRequestSessionId()`** - 验证会话(仅非初始化请求) +7. 获取会话 ID 和会话对象 +8. 根据消息类型分发处理: + - `JSONRPCResponse` → **调用 `handleJsonRpcResponse()`** + - `JSONRPCNotification` → **调用 `handleJsonRpcNotification()`** + - `JSONRPCRequest` → **调用 `handleJsonRpcRequest()`** + +#### c. DELETE 请求处理流程 + +1. 检查服务器是否正在关闭 +2. 检查是否禁用 DELETE 操作 +3. **调用 `validateRequestSessionId()`** - 验证 `mcp-session-id` 头及会话存在性 +4. 提取 `transportContext` 上下文 +5. 获取会话 ID 和会话对象 +6. 删除会话并从会话映射表中移除 + ### 2. SSE 实现改造(核心改造) **原始 SDK (Spring MVC)**: @@ -320,42 +307,6 @@ if(!this.response.isActive()){ } ``` -### 6. JSON-RPC 消息反序列化 - -```java -public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map map) { - // 根据字段判断消息类型 - if (map.containsKey("method") && map.containsKey("id")) { - return jsonMapper.convertValue(map, McpSchema.JSONRPCRequest.class); - } else if (map.containsKey("method") && !map.containsKey("id")) { - return jsonMapper.convertValue(map, McpSchema.JSONRPCNotification.class); - } else if (map.containsKey("result") || map.containsKey("error")) { - return jsonMapper.convertValue(map, McpSchema.JSONRPCResponse.class); - } - throw new IllegalArgumentException(...); -} -``` - -- 智能识别 JSON-RPC 消息类型 - -## 代码结构对照表 - -| 功能模块 | 改造程度 | SDK 原始实现 | FIT 框架实现 | -|------------|----------|----------------------------------------|--------------------------------| -| SSE 实现 | **重大改造** | `SseEmitter` | `Choir` + `Emitter` | -| HTTP 请求对象 | **重大改造** | `HttpServletRequest` | `HttpClassicServerRequest` | -| HTTP 响应对象 | **重大改造** | `HttpServletResponse` | `HttpClassicServerResponse` | -| HTTP返回类型 | **重大改造** | `ResponseEntity` | `Object` (`Entity`或者`Choir`) | -| Get连接检测 | 新增 | 无 | `response.isActive()` | -| 验证工具 | 新增 | 无或其他 | FIT Validation | -| 日志系统 | 轻微改造 | SLF4J | FIT Logger | -| Builder 模式 | 轻微改造 | 原始逻辑 | 类型参数调整 | -| HTTP 注解 | 无变化 | `@GetMapping` (Spring) | `@GetMapping` (FIT) | -| 接口实现 | 无变化 | `McpStreamableServerTransportProvider` | 相同 | -| 会话管理 | 无变化 | 原始逻辑 | 相同 | -| 消息序列化 | 无变化 | 原始逻辑 | 相同 | -| Keep-Alive | 无变化 | 原始逻辑 | 相同 | - ## 参考资源 ### MCP 协议文档 @@ -363,12 +314,9 @@ public McpSchema.JSONRPCMessage deserializeJsonRpcMessage(Map ma - MCP 协议规范:[https://spec.modelcontextprotocol.io/](https://spec.modelcontextprotocol.io/) - MCP SDK GitHub: [https://github.com/modelcontextprotocol/](https://github.com/modelcontextprotocol/) -### FIT 框架文档 - -- FIT HTTP 模块文档:`docs/framework/fit/java/user-guide-book/04. Web MVC 能力.md` -- FIT 流式功能文档:`docs/framework/fit/java/user-guide-book/10. 流式功能.md` -- FIT 日志文档:`docs/framework/fit/java/user-guide-book/08. 日志.md` +### 更新记录 -### 相关类文档 -- `Event` 枚举定义:`modelengine.fel.tool.mcp.entity.Event` -- MCP Server 工具其他实现:`framework/fel/java/plugins/tool-mcp-server/` \ No newline at end of file +| 日期 | 更新内容 | 负责人 | +|----------|---------------------------------|-----| +| 2025-11-04 | 初始版本,从 SDK 改造为 FIT 框架实现 | 黄可欣 | +| 2025-11-05 | 代码重构,提取9个辅助方法提高可读性和可维护性 | 黄可欣 | \ No newline at end of file diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 5fc69195a..92ab1d4b5 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -377,7 +377,7 @@ private Object validatePostAcceptHeaders(HttpClassicServerRequest request, HttpC * @param request The incoming {@link HttpClassicServerRequest} containing the session ID header * @param response The {@link HttpClassicServerResponse} to set status code if validation fails * @return An error {@link Entity} if validation fails (either missing session ID or session not found), - * {@code null} if validation succeeds + * {@code null} if validation succeeds */ private Object validateRequestSessionId(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (!request.headers().contains(HttpHeaders.MCP_SESSION_ID)) { @@ -484,9 +484,11 @@ public void onFailed(Exception cause) { * * @param request The incoming {@link HttpClassicServerRequest} * @param response The {@link HttpClassicServerResponse} to set session ID and initialization result - * @param jsonrpcRequest The {@link McpSchema.JSONRPCRequest} containing {@link McpSchema.InitializeRequest} parameters - * @return An {@link Entity} containing the {@link McpSchema.JSONRPCResponse} with {@link McpSchema.InitializeResult} - * on success, or an error {@link Entity} with {@link McpError} on failure + * @param jsonrpcRequest The {@link McpSchema.JSONRPCRequest} containing {@link McpSchema.InitializeRequest} + * parameters + * @return An {@link Entity} containing the {@link McpSchema.JSONRPCResponse} with + * {@link McpSchema.InitializeResult} + * on success, or an error {@link Entity} with {@link McpError} on failure */ private Object handleInitializeRequest(HttpClassicServerRequest request, HttpClassicServerResponse response, McpSchema.JSONRPCRequest jsonrpcRequest) { From 5f56153f9d8f5bf9d9b9b542b792f2aed2eb857a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Thu, 6 Nov 2025 10:19:01 +0800 Subject: [PATCH 36/37] =?UTF-8?q?=E4=BF=AE=E6=94=B9transport=E7=B1=BBnull?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/plugins/tool-mcp-server/README.md | 9 ++-- ...tMcpStreamableServerTransportProvider.java | 42 +++++++++---------- .../src/main/resources/application.yml | 2 +- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/README.md b/framework/fel/java/plugins/tool-mcp-server/README.md index 79fdad626..8a91ddf80 100644 --- a/framework/fel/java/plugins/tool-mcp-server/README.md +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -3,10 +3,9 @@ ## 文档概述 本文档用于记录 `FitMcpStreamableServerTransportProvider` 类的设计、实现细节以及维护更新指南。该类是基于 MCP SDK 中的 -`WebMvcStreamableServerTransportProvider` 类改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 +`HttpServletStreamableServerTransportProvider` 类改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 -**原始参考类**: MCP SDK 中的 `WebMvcStreamableServerTransportProvider` (或 -`HttpServletStreamableServerTransportProvider`) +**原始参考类**: MCP SDK 中的 `HttpServletStreamableServerTransportProvider` **创建时间**: 2025-11-04 @@ -86,7 +85,7 @@ ## SDK 原始逻辑 -以下是从 MCP SDK 的 `WebMvcStreamableServerTransportProvider` 类保留的原始逻辑: +以下是从 MCP SDK 的 `HttpServletStreamableServerTransportProvider` 类保留的原始逻辑: ### 1. 会话管理核心逻辑 @@ -181,7 +180,7 @@ public Mono closeGracefully() { ### 2. SSE 实现改造(核心改造) -**原始 SDK (Spring MVC)**: +**原始 SDK**: ```java SseEmitter sseEmitter = new SseEmitter(); diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 92ab1d4b5..22732caa2 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -46,7 +46,8 @@ /** * The default implementation of {@link McpStreamableServerTransportProvider}. - * The FIT transport provider for MCP Server, according to {@code WebMvcStreamableServerTransportProvider} in MCP SDK. + * The FIT transport provider for MCP Server, according to {@code HttpServletStreamableServerTransportProvider} in MCP + * SDK. * * @author 黄可欣 * @since 2025-09-30 @@ -143,7 +144,7 @@ public Mono notifyClients(String method, Object params) { try { session.sendNotification(method, params).block(); } catch (Exception e) { - logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage()); + logger.error("Failed to send message to session {}: {}", session.getId(), e.getMessage(), e); } }); }); @@ -164,7 +165,7 @@ public Mono closeGracefully() { try { session.closeGracefully().block(); } catch (Exception e) { - logger.error("Failed to close session {}: {}", session.getId(), e.getMessage()); + logger.error("Failed to close session {}: {}", session.getId(), e.getMessage(), e); } }); @@ -219,7 +220,7 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo } }); } catch (Exception e) { - logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage()); + logger.error("Failed to handle GET request for session {}: {}", sessionId, e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return null; } @@ -266,9 +267,11 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp // Handle JSONRPCMessage if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - return handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); + handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); + return null; } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - return handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); + handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); + return null; } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { return handleJsonRpcRequest(jsonrpcRequest, session, sessionId, transportContext, response); } else { @@ -277,12 +280,12 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); } } catch (IllegalArgumentException | IOException e) { - logger.error("Failed to deserialize message: {}", e.getMessage()); + logger.error("Failed to deserialize message: {}", e.getMessage(), e); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format").build()); } catch (Exception e) { - logger.error("Error handling message: {}", e.getMessage()); + logger.error("Error handling message: {}", e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); @@ -323,7 +326,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe response.statusCode(HttpResponseStatus.OK.statusCode()); return null; } catch (Exception e) { - logger.error("Failed to delete session {}: {}", sessionId, e.getMessage()); + logger.error("Failed to delete session {}: {}", sessionId, e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); @@ -423,12 +426,12 @@ private void handleReplaySseRequest(HttpClassicServerRequest request, McpTranspo .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); } catch (Exception e) { - logger.error("Failed to replay message: {}", e.getMessage()); + logger.error("Failed to replay message: {}", e.getMessage(), e); emitter.fail(e); } }); } catch (Exception e) { - logger.error("Failed to replay messages: {}", e.getMessage()); + logger.error("Failed to replay messages: {}", e.getMessage(), e); emitter.fail(e); } } @@ -507,7 +510,7 @@ private Object handleInitializeRequest(HttpClassicServerRequest request, HttpCla return Entity.createObject(response, new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, jsonrpcRequest.id(), initResult, null)); } catch (Exception e) { - logger.error("Failed to initialize session: {}", e.getMessage()); + logger.error("Failed to initialize session: {}", e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); @@ -523,13 +526,11 @@ private Object handleInitializeRequest(HttpClassicServerRequest request, HttpCla * @param session The {@link McpStreamableServerSession} to accept the response * @param transportContext The {@link McpTransportContext} for request context propagation * @param response The {@link HttpClassicServerResponse} to set the status code - * @return {@code null} as the response status is set to {@code 202 Accepted} */ - private Object handleJsonRpcResponse(McpSchema.JSONRPCResponse jsonrpcResponse, McpStreamableServerSession session, + private void handleJsonRpcResponse(McpSchema.JSONRPCResponse jsonrpcResponse, McpStreamableServerSession session, McpTransportContext transportContext, HttpClassicServerResponse response) { session.accept(jsonrpcResponse).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); - return null; } /** @@ -541,16 +542,14 @@ private Object handleJsonRpcResponse(McpSchema.JSONRPCResponse jsonrpcResponse, * @param session The {@link McpStreamableServerSession} to accept the notification * @param transportContext The {@link McpTransportContext} for request context propagation * @param response The {@link HttpClassicServerResponse} to set the status code - * @return {@code null} as the response status is set to {@code 202 Accepted} */ - private Object handleJsonRpcNotification(McpSchema.JSONRPCNotification jsonrpcNotification, + private void handleJsonRpcNotification(McpSchema.JSONRPCNotification jsonrpcNotification, McpStreamableServerSession session, McpTransportContext transportContext, HttpClassicServerResponse response) { session.accept(jsonrpcNotification) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); response.statusCode(HttpResponseStatus.ACCEPTED.statusCode()); - return null; } /** @@ -593,7 +592,7 @@ public void onFailed(Exception e) { .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); } catch (Exception e) { - logger.error("Failed to handle request stream: {}", e.getMessage()); + logger.error("Failed to handle request stream: {}", e.getMessage(), e); emitter.fail(e); } }); @@ -681,13 +680,14 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId logger.info("[SSE] Sending message to session {}: {}", this.sessionId, jsonText); } catch (Exception e) { - logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage()); + logger.error("Failed to send message to session {}: {}", this.sessionId, e.getMessage(), e); try { this.emitter.fail(e); } catch (Exception errorException) { logger.error("Failed to send error to SSE builder for session {}: {}", this.sessionId, - errorException.getMessage()); + errorException.getMessage(), + errorException); } } finally { this.lock.unlock(); diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml index 6d6cfd452..d3ac184e8 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/resources/application.yml @@ -6,4 +6,4 @@ fit: mcp: server: request: - timeout-seconds: 10 \ No newline at end of file + timeout-seconds: 60 \ No newline at end of file From d3760b8c5a3a4d76526a30848f3637af3fde397d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=8F=AF=E6=AC=A3?= <2218887102@qq.com> Date: Fri, 7 Nov 2025 09:59:58 +0800 Subject: [PATCH 37/37] =?UTF-8?q?=E4=BF=AE=E6=94=B9transportProvider?= =?UTF-8?q?=E7=9A=84handlePOST=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/plugins/tool-mcp-server/README.md | 17 ++--- ...tMcpStreamableServerTransportProvider.java | 63 ++++++++++++------- 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/README.md b/framework/fel/java/plugins/tool-mcp-server/README.md index 8a91ddf80..b9222ebdd 100644 --- a/framework/fel/java/plugins/tool-mcp-server/README.md +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -65,14 +65,15 @@ #### 根据请求类型调用处理逻辑的方法 -| 方法名 | 处理的请求类型 | 说明 | -|--------------------------------|---------|--------------------------------------| -| `handleReplaySseRequest()` | GET | 处理 SSE 消息重放请求,用于断线重连后恢复错过的消息 | -| `handleEstablishSseRequest()` | GET | 处理 SSE 连接建立请求,创建新的持久化 SSE 监听流 | -| `handleInitializeRequest()` | POST | 处理客户端初始化连接请求,创建新的 MCP 会话 | -| `handleJsonRpcResponse()` | POST | 处理 JSON-RPC 响应消息(如 Elicitation 中的客户端响应) | -| `handleJsonRpcNotification()` | POST | 处理 JSON-RPC 通知消息(客户端单向通知) | -| `handleJsonRpcRequest()` | POST | 处理 JSON-RPC 请求消息,返回 SSE 流式响应 | +| 方法名 | 处理的请求类型 | 说明 | +|---------------------------------|---------|------------------------------------------| +| `handleReplaySseRequest()` | GET | 处理 SSE 消息重放请求,用于断线重连后恢复错过的消息 | +| `handleEstablishSseRequest()` | GET | 处理 SSE 连接建立请求,创建新的持久化 SSE 监听流 | +| `handleInitializeRequest()` | POST | 处理客户端初始化连接请求,创建新的 MCP 会话 | +| `handleJsonRpcMessage()` | POST | 把非Initialize的客户端消息分流给下面三个方法,包含Session验证。 | +| `handleJsonRpcResponse()` | POST | 处理 JSON-RPC 响应消息(如 Elicitation 中的客户端响应) | +| `handleJsonRpcNotification()` | POST | 处理 JSON-RPC 通知消息(客户端单向通知) | +| `handleJsonRpcRequest()` | POST | 处理 JSON-RPC 请求消息,返回 SSE 流式响应 | ### 内部类 diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java index 22732caa2..324c427d1 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpStreamableServerTransportProvider.java @@ -249,35 +249,13 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, requestBody); - // Handle initialization request + // Handle JSONRPCMessage if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method() .equals(McpSchema.METHOD_INITIALIZE)) { logger.info("[POST] Handling initialize method, with receiving message: {}", requestBody); return handleInitializeRequest(request, response, jsonrpcRequest); - } - - // Get session ID and session - Object sessionError = validateRequestSessionId(request, response); - if (sessionError != null) { - return sessionError; - } - String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); - McpStreamableServerSession session = this.sessions.get(sessionId); - logger.info("[POST] Receiving message from session {}: {}", sessionId, requestBody); - - // Handle JSONRPCMessage - if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); - return null; - } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); - return null; - } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - return handleJsonRpcRequest(jsonrpcRequest, session, sessionId, transportContext, response); } else { - response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); - return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); + return handleJsonRpcMessage(message, request, requestBody, transportContext, response); } } catch (IllegalArgumentException | IOException e) { logger.error("Failed to deserialize message: {}", e.getMessage(), e); @@ -517,6 +495,43 @@ private Object handleInitializeRequest(HttpClassicServerRequest request, HttpCla } } + /** + * Handles different types of JSON-RPC messages (Response, Notification, Request). + * Routes the message to the appropriate handler method based on its type. + * + * @param message The {@link McpSchema.JSONRPCMessage} to handle + * @param request The incoming {@link HttpClassicServerRequest} + * @param requestBody The {@link String} of request body. + * @param transportContext The {@link McpTransportContext} for request context propagation + * @param response The {@link HttpClassicServerResponse} to set status code and return data + * @return An {@link Entity} or {@link Choir} containing the response data, or {@code null} for accepted messages + */ + private Object handleJsonRpcMessage(McpSchema.JSONRPCMessage message, HttpClassicServerRequest request, + String requestBody, McpTransportContext transportContext, HttpClassicServerResponse response) { + // Get session ID and session + Object sessionError = validateRequestSessionId(request, response); + if (sessionError != null) { + return sessionError; + } + String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); + McpStreamableServerSession session = this.sessions.get(sessionId); + logger.info("[POST] Receiving message from session {}: {}", sessionId, requestBody); + + if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { + handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); + return null; + } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { + handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); + return null; + } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { + return handleJsonRpcRequest(jsonrpcRequest, session, sessionId, transportContext, response); + } else { + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); + } + } + /** * Handles incoming JSON-RPC response messages from clients. * Accepts the response and delivers it to the corresponding pending request within the session.