From 58398dda866856e48246592bd7671940ade4a619 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, 13 Nov 2025 16:34:43 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E6=B7=BB=E5=8A=A0McpSseServerConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/server/config/McpSseServerConfig.java | 54 +++++++++++++++++++ .../McpStreamableServerConfig.java} | 16 ++++-- ...mableServer.java => DefaultMcpServer.java} | 7 ++- ...verTest.java => DefaultMcpServerTest.java} | 24 ++++----- 4 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{McpServerConfig.java => config/McpStreamableServerConfig.java} (70%) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/{DefaultMcpStreamableServer.java => DefaultMcpServer.java} (96%) rename framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/{DefaultMcpStreamableServerTest.java => DefaultMcpServerTest.java} (84%) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java new file mode 100644 index 000000000..7a222e738 --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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.config; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; +import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; +import modelengine.fel.tool.service.ToolExecuteService; +import modelengine.fitframework.annotation.Bean; +import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Value; + +import java.time.Duration; + +/** + * Mcp Server Bean implemented with MCP SDK. + * + * @author 黄可欣 + * @since 2025-10-22 + */ +@Component +public class McpSseServerConfig { + @Bean + public HttpServletSseServerTransportProvider httpServletSseServerTransportProvider() { + return HttpServletSseServerTransportProvider.builder() + .jsonMapper(McpJsonMapper.getDefault()) + .messageEndpoint("/mcp/message") + .sseEndpoint("/mcp/sse") + .build(); + } + + @Bean("McpSyncSseServer") + public McpSyncServer mcpSyncSseServer(HttpServletSseServerTransportProvider transportProvider, + @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { + return McpServer.sync(transportProvider) + .serverInfo("FIT Store MCP Server", "3.6.1-SNAPSHOT") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build()) + .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) + .build(); + } + + @Bean("DefaultMcpSseServer") + public DefaultMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + return new DefaultMcpServer(toolExecuteService, mcpSyncServer); + } +} 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/config/McpStreamableServerConfig.java similarity index 70% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServerConfig.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index e5ff98f9b..44e625261 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/config/McpStreamableServerConfig.java @@ -4,13 +4,16 @@ * 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.config; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; +import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; import modelengine.fitframework.annotation.Value; @@ -24,14 +27,14 @@ * @since 2025-10-22 */ @Component -public class McpServerConfig { +public class McpStreamableServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { return FitMcpStreamableServerTransportProvider.builder().jsonMapper(McpJsonMapper.getDefault()).build(); } - @Bean - public McpSyncServer mcpSyncServer(FitMcpStreamableServerTransportProvider transportProvider, + @Bean("McpSyncStreamableServer") + public McpSyncServer mcpSyncStreamableServer(FitMcpStreamableServerTransportProvider transportProvider, @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { return McpServer.sync(transportProvider) .serverInfo("FIT Store MCP Server", "3.6.1-SNAPSHOT") @@ -39,4 +42,9 @@ public McpSyncServer mcpSyncServer(FitMcpStreamableServerTransportProvider trans .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } + + @Bean("DefaultMcpStreamableServer") + public DefaultMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + return new DefaultMcpServer(toolExecuteService, mcpSyncServer); + } } 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/DefaultMcpServer.java similarity index 96% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServer.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java index f3de70277..df5b4785f 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/DefaultMcpServer.java @@ -36,9 +36,8 @@ * @author 季聿阶 * @since 2025-05-15 */ -@Component -public class DefaultMcpStreamableServer implements McpServer, ToolChangedObserver { - private static final Logger log = Logger.get(DefaultMcpStreamableServer.class); +public class DefaultMcpServer implements McpServer, ToolChangedObserver { + private static final Logger log = Logger.get(DefaultMcpServer.class); private final McpSyncServer mcpSyncServer; private final ToolExecuteService toolExecuteService; @@ -50,7 +49,7 @@ public class DefaultMcpStreamableServer implements McpServer, ToolChangedObserve * @param toolExecuteService The service used to execute tools when handling tool call requests. * @throws IllegalArgumentException If {@code toolExecuteService} is null. */ - public DefaultMcpStreamableServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + public DefaultMcpServer(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/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/DefaultMcpServerTest.java similarity index 84% rename from framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpStreamableServerTest.java rename to framework/fel/java/plugins/tool-mcp-server/src/test/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServerTest.java index 0e411778f..9a18a7e7d 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/DefaultMcpServerTest.java @@ -15,7 +15,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.mcp.server.config.McpStreamableServerConfig; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; @@ -29,21 +29,21 @@ import java.util.Map; /** - * Unit test for {@link DefaultMcpStreamableServer}. + * Unit test for {@link DefaultMcpServer}. * * @author 季聿阶 * @since 2025-05-20 */ -@DisplayName("Unit tests for DefaultMcpStreamableServer") -public class DefaultMcpStreamableServerTest { +@DisplayName("Unit tests for DefaultMcpServer") +public class DefaultMcpServerTest { private ToolExecuteService toolExecuteService; private McpSyncServer mcpSyncServer; @BeforeEach void setup() { this.toolExecuteService = mock(ToolExecuteService.class); - McpServerConfig config = new McpServerConfig(); - this.mcpSyncServer = config.mcpSyncServer(config.fitMcpStreamableServerTransportProvider(), 10); + McpStreamableServerConfig config = new McpStreamableServerConfig(); + this.mcpSyncServer = config.mcpSyncStreamableServer(config.fitMcpStreamableServerTransportProvider(), 10); } @Nested @@ -53,7 +53,7 @@ class GivenConstructor { @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { IllegalArgumentException exception = catchThrowableOfType(IllegalArgumentException.class, - () -> new DefaultMcpStreamableServer(null, mcpSyncServer)); + () -> new DefaultMcpServer(null, mcpSyncServer)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -64,7 +64,7 @@ class GivenRegisterAndNotify { @Test @DisplayName("Should notify observers when tools are added or removed") void notifyObserversOnToolAddOrRemove() { - DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); @@ -87,7 +87,7 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get() @@ -110,7 +110,7 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -134,7 +134,7 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -150,7 +150,7 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultMcpStreamableServer server = new DefaultMcpStreamableServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) From d716656218402afd63077ee737cd3a93bb745be9 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, 14 Nov 2025 11:24:40 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E6=B7=BB=E5=8A=A0FitMcpSseServerTranspor?= =?UTF-8?q?tProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FitMcpSseServerTransportProvider.java | 522 ++++++++++++++++++ 1 file changed, 522 insertions(+) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java new file mode 100644 index 000000000..3be8f4bf0 --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -0,0 +1,522 @@ +/*--------------------------------------------------------------------------------------------- + * 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.transport; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.spec.ProtocolVersions; +import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.KeepAliveScheduler; +import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fit.http.annotation.GetMapping; +import modelengine.fit.http.annotation.PostMapping; +import modelengine.fit.http.annotation.RequestParam; +import modelengine.fit.http.entity.Entity; +import modelengine.fit.http.entity.TextEvent; +import modelengine.fit.http.protocol.HttpResponseStatus; +import modelengine.fit.http.server.HttpClassicServerRequest; +import modelengine.fit.http.server.HttpClassicServerResponse; +import modelengine.fitframework.flowable.Choir; +import modelengine.fitframework.flowable.Emitter; +import modelengine.fitframework.log.Logger; +import reactor.core.publisher.Flux; +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; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Server-side implementation of the Model Context Protocol (MCP) transport layer using + * HTTP with Server-Sent Events (SSE) through Spring WebMVC. This implementation provides + * a bridge between synchronous WebMVC operations and reactive programming patterns to + * maintain compatibility with the reactive transport interface. + * + *

+ * Key features: + *

    + *
  • Implements bidirectional communication using HTTP POST for client-to-server + * messages and SSE for server-to-client messages
  • + *
  • Manages client sessions with unique IDs for reliable message delivery
  • + *
  • Supports graceful shutdown with proper session cleanup
  • + *
  • Provides JSON-RPC message handling through configured endpoints
  • + *
  • Includes built-in error handling and logging
  • + *
+ * + *

+ * The transport operates on two main endpoints: + *

    + *
  • {@code /sse} - The SSE endpoint where clients establish their event stream + * connection
  • + *
  • A configurable message endpoint where clients send their JSON-RPC messages via HTTP + * POST
  • + *
+ * + *

+ * This implementation uses {@link ConcurrentHashMap} to safely manage multiple client + * sessions in a thread-safe manner. Each client session is assigned a unique ID and + * maintains its own SSE connection. + * + * @author Christian Tzolov + * @author Alexandros Pappas + * @see McpServerTransportProvider + */ +public class FitMcpSseServerTransportProvider implements McpServerTransportProvider { + private static final Logger logger = Logger.get(FitMcpSseServerTransportProvider.class); + private static final String MESSAGE_ENDPOINT = "/mcp/message"; + private static final String SSE_ENDPOINT = "/mcp/sse"; + /** + * Event type for sending the message endpoint URI to clients. + */ + public static final String ENDPOINT_EVENT_TYPE = "endpoint"; + + private final McpJsonMapper jsonMapper; + private McpServerSession.Factory sessionFactory; + private final Map sessions = new ConcurrentHashMap(); + private McpTransportContextExtractor contextExtractor; + private volatile boolean isClosing = false; + private KeepAliveScheduler keepAliveScheduler; + + /** + * Constructs a new FitMcpSseServerTransportProvider instance. + * + * @param jsonMapper The McpJsonMapper to use for JSON serialization/deserialization + * of messages. + * @param keepAliveInterval The interval for sending keep-alive messages to clients. + * @param contextExtractor The contextExtractor to fill in a + * {@link McpTransportContext}. + * @throws IllegalArgumentException if any parameter is null + */ + private FitMcpSseServerTransportProvider(McpJsonMapper jsonMapper, Duration keepAliveInterval, + McpTransportContextExtractor contextExtractor) { + Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); + Assert.notNull(contextExtractor, "Context extractor must not be null"); + this.jsonMapper = jsonMapper; + this.contextExtractor = contextExtractor; + if (keepAliveInterval != null) { + this.keepAliveScheduler = KeepAliveScheduler.builder(() -> this.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); + } + + @Override + public void setSessionFactory(McpServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + /** + * Broadcasts a notification to all connected clients through their SSE connections. + * The message is serialized to JSON and sent as an SSE event with type "message". 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()) { + 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(); + } + + /** + * Initiates a graceful shutdown of the transport. This method: + *

    + *
  • Sets the closing flag to prevent new connections
  • + *
  • Closes all active SSE connections
  • + *
  • Removes all session records
  • + *
+ * + * @return A Mono that completes when all cleanup operations are finished + */ + @Override + public Mono closeGracefully() { + return Flux.fromIterable(sessions.values()).doFirst(() -> { + this.isClosing = true; + logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()); + }).flatMap(McpServerSession::closeGracefully).then().doOnSuccess(v -> { + logger.debug("Graceful shutdown completed"); + sessions.clear(); + if (this.keepAliveScheduler != null) { + this.keepAliveScheduler.shutdown(); + } + }); + } + + /** + * Handles new SSE connection requests from clients by creating a new session and + * establishing an SSE connection. This method: + *
    + *
  • Generates a unique session ID
  • + *
  • Creates a new session with a FitMcpSessionTransport
  • + *
  • Sends an initial endpoint event to inform the client where to send + * messages
  • + *
  • Maintains the session in the sessions map
  • + *
+ * + * @param request The incoming server request + * @return A ServerResponse configured for SSE communication, or an error response if + * the server is shutting down or the connection fails + */ + @GetMapping(path = SSE_ENDPOINT) + private Object handleSseConnection(HttpClassicServerRequest request, HttpClassicServerResponse response) { + if (this.isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + return Entity.createText(response, "Server is shutting down"); + } + + String sessionId = UUID.randomUUID().toString(); + logger.debug("Creating new SSE connection for session: {}", sessionId); + try { + return Choir.create(emitter -> { + this.addEmitterObserver(emitter, sessionId); + FitMcpSessionTransport sessionTransport = new FitMcpSessionTransport(sessionId, emitter); + McpServerSession session = sessionFactory.create(sessionTransport); + this.sessions.put(sessionId, session); + + try { + String initData = MESSAGE_ENDPOINT + "?sessionId=" + sessionId; + TextEvent textEvent = + TextEvent.custom().id(sessionId).event(ENDPOINT_EVENT_TYPE).data(initData).build(); + emitter.emit(textEvent); + logger.info("[SSE] Sending init data to session. [sessionId={}, initData={}]", sessionId, initData); + + } catch (Exception e) { + logger.error("Failed to send initial endpoint event: {}", e.getMessage()); + emitter.fail(e); + } + }); + } catch (Exception e) { + logger.error("[GET] Failed to handle GET request. [sessionId={}, error={}]", sessionId, e.getMessage(), e); + sessions.remove(sessionId); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + return null; + } + } + + /** + * Handles incoming JSON-RPC messages from clients. This method: + *
    + *
  • Deserializes the request body into a JSON-RPC message
  • + *
  • Processes the message through the session's handle method
  • + *
  • Returns appropriate HTTP responses based on the processing result
  • + *
+ * + * @param request The incoming server request containing the JSON-RPC message + * @return A ServerResponse indicating success (200 OK) or appropriate error status + * with error details in case of failures + */ + @PostMapping(path = MESSAGE_ENDPOINT) + private Object handleMessage(HttpClassicServerRequest request, HttpClassicServerResponse response, + @RequestParam("sessionId") String sessionId) { + if (this.isClosing) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + return Entity.createText(response, "Server is shutting down"); + } + Object sessionError = validateRequestSessionId(sessionId, response); + if (sessionError != null) { + return sessionError; + } + + McpServerSession session = this.sessions.get(sessionId); + logger.info("[POST] Receiving delete request. [sessionId={}]", sessionId); + try { + final McpTransportContext transportContext = this.contextExtractor.extract(request); + + String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, requestBody); + session.handle(message).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); + response.statusCode(HttpResponseStatus.OK.statusCode()); + return null; + } catch (IllegalArgumentException | IOException e) { + logger.error("[POST] Failed to deserialize message. [error={}]", 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("[POST] Error handling message. [error={}]", e.getMessage(), e); + response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(e.getMessage()).build()); + } + } + + private void addEmitterObserver(Emitter emitter, String sessionId) { + emitter.observe(new Emitter.Observer() { + @Override + public void onEmittedData(TextEvent data) { + // No action needed + } + + @Override + public void onCompleted() { + logger.info("[SSE] Completed SSE emitting. [sessionId={}]", sessionId); + try { + FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); + } catch (Exception e) { + logger.warn("[SSE] Error closing listeningStream on complete. [sessionId={}, error={}]", + sessionId, + e.getMessage()); + } + } + + @Override + public void onFailed(Exception cause) { + logger.warn("[SSE] SSE failed. [sessionId={}, cause={}]", sessionId, cause.getMessage()); + try { + FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); + } catch (Exception e) { + logger.warn("[SSE] Error closing listeningStream on failure. [sessionId={}, error={}]", + sessionId, + e.getMessage()); + } + } + }); + } + + /** + * 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 sessionId The {@link String} session ID in request parameter. + * @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(String sessionId, HttpClassicServerResponse response) { + if (sessionId.isEmpty()) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + return Entity.createText(response, "Session ID missing in message endpoint"); + } + 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; + } + + /** + * Implementation of McpServerTransport for WebMVC SSE sessions. This class handles + * the transport-level communication for a specific client session. + */ + private class FitMcpSessionTransport implements McpServerTransport { + private final String sessionId; + private final Emitter emitter; + + /** + * Lock to ensure thread-safe access to the SSE builder when sending messages. + * This prevents concurrent modifications that could lead to corrupted SSE events. + */ + private final ReentrantLock sseBuilderLock = new ReentrantLock(); + + /** + * 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 + */ + FitMcpSessionTransport(String sessionId, Emitter emitter) { + this.sessionId = sessionId; + this.emitter = emitter; + logger.info("[SSE] Building SSE emitter. [sessionId={}]", 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 Mono.fromRunnable(() -> { + sseBuilderLock.lock(); + try { + String jsonText = jsonMapper.writeValueAsString(message); + TextEvent textEvent = + TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); + this.emitter.emit(textEvent); + FitMcpSseServerTransportProvider.logger.debug("Message sent to session {}", this.sessionId); + } catch (Exception e) { + logger.error("Failed to send message to session {}: {}", sessionId, e.getMessage()); + this.emitter.fail(e); + } finally { + sseBuilderLock.unlock(); + } + }); + } + + /** + * Converts data from one type to another using the configured McpJsonMapper. + * + * @param data The source data object to convert + * @param typeRef The target type reference + * @param The target type + * @return The converted object of type T + */ + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return jsonMapper.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(() -> { + logger.debug("Closing session transport: {}", sessionId); + sseBuilderLock.lock(); + try { + this.emitter.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 { + sseBuilderLock.unlock(); + } + }); + } + + /** + * Closes the transport immediately. + */ + @Override + public void close() { + sseBuilderLock.lock(); + try { + this.emitter.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 { + sseBuilderLock.unlock(); + } + } + + } + + /** + * Creates a new Builder instance for configuring and creating instances of + * FitMcpSseServerTransportProvider. + * + * @return A new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for creating instances of FitMcpSseServerTransportProvider. + *

+ * This builder provides a fluent API for configuring and creating instances of + * FitMcpSseServerTransportProvider with custom settings. + */ + public static class Builder { + private McpJsonMapper jsonMapper; + private Duration keepAliveInterval; + private McpTransportContextExtractor contextExtractor = + (serverRequest) -> McpTransportContext.EMPTY; + + /** + * Sets the JSON object mapper to use for message serialization/deserialization. + * + * @param jsonMapper The object mapper to use + * @return This builder instance for method chaining + */ + public Builder jsonMapper(McpJsonMapper jsonMapper) { + Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); + this.jsonMapper = jsonMapper; + return this; + } + + /** + * Sets the interval for keep-alive pings. + *

+ * If not specified, keep-alive pings will be disabled. + * + * @param keepAliveInterval The interval duration for keep-alive pings + * @return This builder instance for method chaining + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + 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; + } + + /** + * Builds a new instance of FitMcpSseServerTransportProvider with the configured + * settings. + * + * @return A new FitMcpSseServerTransportProvider instance + * @throws IllegalStateException if jsonMapper or messageEndpoint is not set + */ + public FitMcpSseServerTransportProvider build() { + return new FitMcpSseServerTransportProvider( + this.jsonMapper == null ? McpJsonMapper.getDefault() : this.jsonMapper, + this.keepAliveInterval, + this.contextExtractor); + } + } +} From 5e34f602145b6bdc9440a4f08c578dae9331cab2 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, 14 Nov 2025 11:37:48 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E6=9B=B4=E6=96=B0bean=E6=8C=89=E5=90=8D?= =?UTF-8?q?=E7=A7=B0=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/server/config/McpSseServerConfig.java | 19 ++++++++----------- .../config/McpStreamableServerConfig.java | 5 +++-- .../FitMcpSseServerTransportProvider.java | 4 ++-- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index 7a222e738..93f9a898c 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -9,13 +9,13 @@ import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; -import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; +import modelengine.fel.tool.mcp.server.transport.FitMcpSseServerTransportProvider; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -24,21 +24,17 @@ * Mcp Server Bean implemented with MCP SDK. * * @author 黄可欣 - * @since 2025-10-22 + * @since 2025-11-10 */ @Component public class McpSseServerConfig { @Bean - public HttpServletSseServerTransportProvider httpServletSseServerTransportProvider() { - return HttpServletSseServerTransportProvider.builder() - .jsonMapper(McpJsonMapper.getDefault()) - .messageEndpoint("/mcp/message") - .sseEndpoint("/mcp/sse") - .build(); + public FitMcpSseServerTransportProvider fitMcpSseServerTransportProvider() { + return FitMcpSseServerTransportProvider.builder().jsonMapper(McpJsonMapper.getDefault()).build(); } @Bean("McpSyncSseServer") - public McpSyncServer mcpSyncSseServer(HttpServletSseServerTransportProvider transportProvider, + public McpSyncServer mcpSyncSseServer(FitMcpSseServerTransportProvider transportProvider, @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { return McpServer.sync(transportProvider) .serverInfo("FIT Store MCP Server", "3.6.1-SNAPSHOT") @@ -48,7 +44,8 @@ public McpSyncServer mcpSyncSseServer(HttpServletSseServerTransportProvider tran } @Bean("DefaultMcpSseServer") - public DefaultMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + public DefaultMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncServer) { return new DefaultMcpServer(toolExecuteService, mcpSyncServer); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index 44e625261..40d59ca41 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -9,13 +9,13 @@ import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -44,7 +44,8 @@ public McpSyncServer mcpSyncStreamableServer(FitMcpStreamableServerTransportProv } @Bean("DefaultMcpStreamableServer") - public DefaultMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + public DefaultMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncServer) { return new DefaultMcpServer(toolExecuteService, mcpSyncServer); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 3be8f4bf0..8716786fc 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -73,8 +73,8 @@ * sessions in a thread-safe manner. Each client session is assigned a unique ID and * maintains its own SSE connection. * - * @author Christian Tzolov - * @author Alexandros Pappas + * @author 黄可欣 + * @since 2025-11-10 * @see McpServerTransportProvider */ public class FitMcpSseServerTransportProvider implements McpServerTransportProvider { From 0bfedf405f63047c0a303ee4b9863245cf9e7610 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, 14 Nov 2025 16:20:29 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9DefaultMcpServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/server/config/McpSseServerConfig.java | 16 ++-- .../config/McpStreamableServerConfig.java | 16 ++-- .../mcp/server/support/DefaultMcpServer.java | 30 +++++-- .../FitMcpSseServerTransportProvider.java | 81 ++++++++++--------- .../server/support/DefaultMcpServerTest.java | 29 ++++--- 5 files changed, 95 insertions(+), 77 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index 93f9a898c..66155c306 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -10,12 +10,9 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpSseServerTransportProvider; -import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; -import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -30,22 +27,19 @@ public class McpSseServerConfig { @Bean public FitMcpSseServerTransportProvider fitMcpSseServerTransportProvider() { - return FitMcpSseServerTransportProvider.builder().jsonMapper(McpJsonMapper.getDefault()).build(); + return FitMcpSseServerTransportProvider.builder() + .jsonMapper(McpJsonMapper.getDefault()) + .keepAliveInterval(Duration.ofSeconds(30)) + .build(); } @Bean("McpSyncSseServer") public McpSyncServer mcpSyncSseServer(FitMcpSseServerTransportProvider transportProvider, @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { return McpServer.sync(transportProvider) - .serverInfo("FIT Store MCP Server", "3.6.1-SNAPSHOT") + .serverInfo("FIT Store MCP SSE Server", "3.6.1-SNAPSHOT") .capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build()) .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } - - @Bean("DefaultMcpSseServer") - public DefaultMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, - @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncServer) { - return new DefaultMcpServer(toolExecuteService, mcpSyncServer); - } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index 40d59ca41..2ff3c5685 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -10,12 +10,9 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; -import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; -import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -30,22 +27,19 @@ public class McpStreamableServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { - return FitMcpStreamableServerTransportProvider.builder().jsonMapper(McpJsonMapper.getDefault()).build(); + return FitMcpStreamableServerTransportProvider.builder() + .jsonMapper(McpJsonMapper.getDefault()) + .keepAliveInterval(Duration.ofSeconds(30)) + .build(); } @Bean("McpSyncStreamableServer") public McpSyncServer mcpSyncStreamableServer(FitMcpStreamableServerTransportProvider transportProvider, @Value("${mcp.server.request.timeout-seconds}") int requestTimeoutSeconds) { return McpServer.sync(transportProvider) - .serverInfo("FIT Store MCP Server", "3.6.1-SNAPSHOT") + .serverInfo("FIT Store MCP Streamable Server", "3.6.1-SNAPSHOT") .capabilities(McpSchema.ServerCapabilities.builder().tools(true).logging().build()) .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } - - @Bean("DefaultMcpStreamableServer") - public DefaultMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, - @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncServer) { - return new DefaultMcpServer(toolExecuteService, mcpSyncServer); - } } 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 index df5b4785f..6d1c61779 100644 --- 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 @@ -19,6 +19,7 @@ import modelengine.fel.tool.service.ToolChangedObserver; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.log.Logger; import modelengine.fitframework.util.MapUtils; import modelengine.fitframework.util.StringUtils; @@ -36,9 +37,11 @@ * @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 McpSyncServer mcpSyncSseServer; + private final McpSyncServer mcpSyncStreamableServer; private final ToolExecuteService toolExecuteService; private final List toolsChangedObservers = new ArrayList<>(); @@ -49,14 +52,20 @@ 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 DefaultMcpServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncSseServer, + @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncStreamableServer) { this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); - this.mcpSyncServer = mcpSyncServer; + this.mcpSyncSseServer = mcpSyncSseServer; + this.mcpSyncStreamableServer = mcpSyncStreamableServer; } @Override public List getTools() { - return this.mcpSyncServer.listTools().stream().map(this::convertToFelTool).collect(Collectors.toList()); + return this.mcpSyncStreamableServer.listTools() + .stream() + .map(this::convertToFelTool) + .collect(Collectors.toList()); } @Override @@ -87,8 +96,14 @@ public void onToolAdded(String name, String description, Map par McpServerFeatures.SyncToolSpecification toolSpecification = createToolSpecification(name, description, parameters); - - this.mcpSyncServer.addTool(toolSpecification); + try { + this.mcpSyncSseServer.addTool(toolSpecification); + this.mcpSyncStreamableServer.addTool(toolSpecification); + } catch (Exception e) { + log.error("Failed to added tool to MCP server. [toolName={}]", name); + this.mcpSyncSseServer.removeTool(name); + throw e; + } log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } @@ -99,7 +114,8 @@ public void onToolRemoved(String name) { log.warn("Tool removal is ignored: tool name is blank."); return; } - this.mcpSyncServer.removeTool(name); + this.mcpSyncSseServer.removeTool(name); + this.mcpSyncStreamableServer.removeTool(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/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 8716786fc..ee63159f7 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -29,6 +29,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; @@ -74,8 +75,8 @@ * maintains its own SSE connection. * * @author 黄可欣 - * @since 2025-11-10 * @see McpServerTransportProvider + * @since 2025-11-10 */ public class FitMcpSseServerTransportProvider implements McpServerTransportProvider { private static final Logger logger = Logger.get(FitMcpSseServerTransportProvider.class); @@ -198,7 +199,7 @@ public Mono closeGracefully() { * the server is shutting down or the connection fails */ @GetMapping(path = SSE_ENDPOINT) - private Object handleSseConnection(HttpClassicServerRequest request, HttpClassicServerResponse response) { + public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); return Entity.createText(response, "Server is shutting down"); @@ -209,7 +210,8 @@ private Object handleSseConnection(HttpClassicServerRequest request, HttpClassic try { return Choir.create(emitter -> { this.addEmitterObserver(emitter, sessionId); - FitMcpSessionTransport sessionTransport = new FitMcpSessionTransport(sessionId, emitter); + FitSseMcpSessionTransport sessionTransport = + new FitSseMcpSessionTransport(sessionId, emitter, response); McpServerSession session = sessionFactory.create(sessionTransport); this.sessions.put(sessionId, session); @@ -246,7 +248,7 @@ private Object handleSseConnection(HttpClassicServerRequest request, HttpClassic * with error details in case of failures */ @PostMapping(path = MESSAGE_ENDPOINT) - private Object handleMessage(HttpClassicServerRequest request, HttpClassicServerResponse response, + public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerResponse response, @RequestParam("sessionId") String sessionId) { if (this.isClosing) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); @@ -258,12 +260,14 @@ private Object handleMessage(HttpClassicServerRequest request, HttpClassicServer } McpServerSession session = this.sessions.get(sessionId); - logger.info("[POST] Receiving delete request. [sessionId={}]", sessionId); try { final McpTransportContext transportContext = this.contextExtractor.extract(request); String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, requestBody); + logger.info("[POST] Receiving message from session. [sessionId={}, requestBody={}]", + sessionId, + requestBody); session.handle(message).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); response.statusCode(HttpResponseStatus.OK.statusCode()); return null; @@ -289,26 +293,16 @@ public void onEmittedData(TextEvent data) { @Override public void onCompleted() { - logger.info("[SSE] Completed SSE emitting. [sessionId={}]", sessionId); - try { - FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); - } catch (Exception e) { - logger.warn("[SSE] Error closing listeningStream on complete. [sessionId={}, error={}]", - sessionId, - e.getMessage()); - } + FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); + logger.info("[SSE] Completed SSE emitting and closed session successfully. [sessionId={}]", sessionId); } @Override public void onFailed(Exception cause) { - logger.warn("[SSE] SSE failed. [sessionId={}, cause={}]", sessionId, cause.getMessage()); - try { - FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); - } catch (Exception e) { - logger.warn("[SSE] Error closing listeningStream on failure. [sessionId={}, error={}]", - sessionId, - e.getMessage()); - } + FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); + logger.warn("[SSE] SSE failed, session closed. [sessionId={}, cause={}]", + sessionId, + cause.getMessage()); } }); } @@ -342,9 +336,10 @@ private Object validateRequestSessionId(String sessionId, HttpClassicServerRespo * Implementation of McpServerTransport for WebMVC SSE sessions. This class handles * the transport-level communication for a specific client session. */ - private class FitMcpSessionTransport implements McpServerTransport { + private class FitSseMcpSessionTransport implements McpServerTransport { private final String sessionId; private final Emitter emitter; + private final HttpClassicServerResponse response; /** * Lock to ensure thread-safe access to the SSE builder when sending messages. @@ -358,9 +353,10 @@ private class FitMcpSessionTransport implements McpServerTransport { * @param sessionId The unique identifier for this session * @param emitter The emitter for sending events */ - FitMcpSessionTransport(String sessionId, Emitter emitter) { + FitSseMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { this.sessionId = sessionId; this.emitter = emitter; + this.response = response; logger.info("[SSE] Building SSE emitter. [sessionId={}]", sessionId); } @@ -374,14 +370,27 @@ private class FitMcpSessionTransport implements McpServerTransport { public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.fromRunnable(() -> { sseBuilderLock.lock(); + // Check if connection is still active before sending + if (!this.response.isActive()) { + logger.warn("[SSE] Connection inactive detected while sending message. [sessionId={}]", + this.sessionId); + this.close(); + return; + } + try { String jsonText = jsonMapper.writeValueAsString(message); TextEvent textEvent = TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); - FitMcpSseServerTransportProvider.logger.debug("Message sent to session {}", this.sessionId); + logger.info("[SSE] Sending message to session. [sessionId={}, jsonText={}]", + this.sessionId, + jsonText); } catch (Exception e) { - logger.error("Failed to send message to session {}: {}", sessionId, e.getMessage()); + logger.error("[SSE] Failed to send message to session. [sessionId={}, error={}]", + this.sessionId, + e.getMessage(), + e); this.emitter.fail(e); } finally { sseBuilderLock.unlock(); @@ -409,18 +418,7 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { */ @Override public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - logger.debug("Closing session transport: {}", sessionId); - sseBuilderLock.lock(); - try { - this.emitter.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 { - sseBuilderLock.unlock(); - } - }); + return Mono.fromRunnable(FitMcpSseServerTransportProvider.FitSseMcpSessionTransport.this::close); } /** @@ -429,11 +427,14 @@ public Mono closeGracefully() { @Override public void close() { sseBuilderLock.lock(); + logger.debug("[SSE] Closing session transport. [sessionId={}]", sessionId); try { this.emitter.complete(); - logger.debug("Successfully completed SSE builder for session {}", sessionId); + logger.info("[SSE] Closed SSE builder successfully. [sessionId={}]", sessionId); } catch (Exception e) { - logger.warn("Failed to complete SSE builder for session {}: {}", sessionId, e.getMessage()); + logger.warn("[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", + sessionId, + e.getMessage()); } finally { sseBuilderLock.unlock(); } @@ -513,6 +514,8 @@ public Builder contextExtractor(McpTransportContextExtractor new DefaultMcpServer(null, mcpSyncServer)); + () -> new DefaultMcpServer(null, mcpSyncSseServer, mcpStreamableSyncServer)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -64,7 +70,8 @@ class GivenRegisterAndNotify { @Test @DisplayName("Should notify observers when tools are added or removed") void notifyObserversOnToolAddOrRemove() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = + new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); server.registerToolsChangedObserver(observer); @@ -87,7 +94,8 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = + new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get() @@ -110,7 +118,8 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = + new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -134,7 +143,8 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = + new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -150,7 +160,8 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultMcpServer server = new DefaultMcpServer(toolExecuteService, mcpSyncServer); + DefaultMcpServer server = + new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) From c69cdcfc3d951e6dad0511497252b5c7ddd17a5f 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, 14 Nov 2025 17:09:14 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/FitMcpSseServerTransportProvider.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index ee63159f7..966863e51 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -148,13 +148,14 @@ public Mono notifyClients(String method, Object params) { return Mono.empty(); } - logger.debug("Attempting to broadcast message to {} active sessions", sessions.size()); + logger.debug("Attempting to broadcast message. [activeSessions={}]", sessions.size()); return Flux.fromIterable(sessions.values()) .flatMap(session -> session.sendNotification(method, params) - .doOnError(e -> logger.error("Failed to send message to session {}: {}", + .doOnError(e -> logger.error("Failed to send message to session. [sessionId={}, error={}]", session.getId(), - e.getMessage())) + e.getMessage(), + e)) .onErrorComplete()) .then(); } @@ -173,7 +174,7 @@ public Mono notifyClients(String method, Object params) { public Mono closeGracefully() { return Flux.fromIterable(sessions.values()).doFirst(() -> { this.isClosing = true; - logger.debug("Initiating graceful shutdown with {} active sessions", sessions.size()); + logger.debug("Initiating graceful shutdown. [activeSessions={}]", sessions.size()); }).flatMap(McpServerSession::closeGracefully).then().doOnSuccess(v -> { logger.debug("Graceful shutdown completed"); sessions.clear(); @@ -206,7 +207,7 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS } String sessionId = UUID.randomUUID().toString(); - logger.debug("Creating new SSE connection for session: {}", sessionId); + logger.debug("Creating new SSE connection. [sessionId={}]", sessionId); try { return Choir.create(emitter -> { this.addEmitterObserver(emitter, sessionId); @@ -223,7 +224,7 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS logger.info("[SSE] Sending init data to session. [sessionId={}, initData={}]", sessionId, initData); } catch (Exception e) { - logger.error("Failed to send initial endpoint event: {}", e.getMessage()); + logger.error("Failed to send initial endpoint event. [error={}]", e.getMessage(), e); emitter.fail(e); } }); From 2a79ba3d358998b7cc71c97db72c0e9a05ac9edc 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, 14 Nov 2025 17:20:07 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/server/support/DefaultMcpServer.java | 2 +- .../FitMcpSseServerTransportProvider.java | 35 +++---------------- ...tMcpStreamableServerTransportProvider.java | 2 +- 3 files changed, 6 insertions(+), 33 deletions(-) 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 index 6d1c61779..6ec8c67f3 100644 --- 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 @@ -32,7 +32,7 @@ /** * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} - * with MCP Server Bean {@link McpSyncServer}. + * with two MCP Server {@link McpSyncServer} Bean for SSE Server and Streamable Server. * * @author 季聿阶 * @since 2025-05-15 diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 966863e51..b5c065090 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -44,39 +44,12 @@ import java.util.concurrent.locks.ReentrantLock; /** - * Server-side implementation of the Model Context Protocol (MCP) transport layer using - * HTTP with Server-Sent Events (SSE) through Spring WebMVC. This implementation provides - * a bridge between synchronous WebMVC operations and reactive programming patterns to - * maintain compatibility with the reactive transport interface. - * - *

- * Key features: - *

    - *
  • Implements bidirectional communication using HTTP POST for client-to-server - * messages and SSE for server-to-client messages
  • - *
  • Manages client sessions with unique IDs for reliable message delivery
  • - *
  • Supports graceful shutdown with proper session cleanup
  • - *
  • Provides JSON-RPC message handling through configured endpoints
  • - *
  • Includes built-in error handling and logging
  • - *
- * - *

- * The transport operates on two main endpoints: - *

    - *
  • {@code /sse} - The SSE endpoint where clients establish their event stream - * connection
  • - *
  • A configurable message endpoint where clients send their JSON-RPC messages via HTTP - * POST
  • - *
- * - *

- * This implementation uses {@link ConcurrentHashMap} to safely manage multiple client - * sessions in a thread-safe manner. Each client session is assigned a unique ID and - * maintains its own SSE connection. + * The default implementation of {@link McpServerTransportProvider}. + * The FIT transport provider for MCP SSE Server, according to {@code HttpServletSseServerTransportProvider} in MCP + * SDK. * * @author 黄可欣 - * @see McpServerTransportProvider - * @since 2025-11-10 + * @since 2025-09-30 */ public class FitMcpSseServerTransportProvider implements McpServerTransportProvider { private static final Logger logger = Logger.get(FitMcpSseServerTransportProvider.class); 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 9bf5bbfe4..564fa1996 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,7 @@ /** * The default implementation of {@link McpStreamableServerTransportProvider}. - * The FIT transport provider for MCP Server, according to {@code HttpServletStreamableServerTransportProvider} in MCP + * The FIT transport provider for MCP Streamable Server, according to {@code HttpServletStreamableServerTransportProvider} in MCP * SDK. * * @author 黄可欣 From 79b6852f263fdf589e925eb13207f610cb5c9f32 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, 17 Nov 2025 11:23:13 +0800 Subject: [PATCH 07/18] =?UTF-8?q?=E6=89=80=E6=9C=89=E6=B7=BB=E5=8A=A0this?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FitMcpSseServerTransportProvider.java | 24 ++++++------- ...tMcpStreamableServerTransportProvider.java | 34 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index b5c065090..c28fc9211 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -116,14 +116,14 @@ public void setSessionFactory(McpServerSession.Factory sessionFactory) { */ @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. [activeSessions={}]", sessions.size()); + logger.debug("Attempting to broadcast message. [activeSessions={}]", this.sessions.size()); - return Flux.fromIterable(sessions.values()) + return Flux.fromIterable(this.sessions.values()) .flatMap(session -> session.sendNotification(method, params) .doOnError(e -> logger.error("Failed to send message to session. [sessionId={}, error={}]", session.getId(), @@ -145,12 +145,12 @@ public Mono notifyClients(String method, Object params) { */ @Override public Mono closeGracefully() { - return Flux.fromIterable(sessions.values()).doFirst(() -> { + return Flux.fromIterable(this.sessions.values()).doFirst(() -> { this.isClosing = true; - logger.debug("Initiating graceful shutdown. [activeSessions={}]", sessions.size()); + logger.debug("Initiating graceful shutdown. [activeSessions={}]", this.sessions.size()); }).flatMap(McpServerSession::closeGracefully).then().doOnSuccess(v -> { logger.debug("Graceful shutdown completed"); - sessions.clear(); + this.sessions.clear(); if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); } @@ -186,7 +186,7 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS this.addEmitterObserver(emitter, sessionId); FitSseMcpSessionTransport sessionTransport = new FitSseMcpSessionTransport(sessionId, emitter, response); - McpServerSession session = sessionFactory.create(sessionTransport); + McpServerSession session = this.sessionFactory.create(sessionTransport); this.sessions.put(sessionId, session); try { @@ -203,7 +203,7 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS }); } catch (Exception e) { logger.error("[GET] Failed to handle GET request. [sessionId={}, error={}]", sessionId, e.getMessage(), e); - sessions.remove(sessionId); + this.sessions.remove(sessionId); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return null; } @@ -228,7 +228,7 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); return Entity.createText(response, "Server is shutting down"); } - Object sessionError = validateRequestSessionId(sessionId, response); + Object sessionError = this.validateRequestSessionId(sessionId, response); if (sessionError != null) { return sessionError; } @@ -238,7 +238,7 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR final McpTransportContext transportContext = this.contextExtractor.extract(request); String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, requestBody); + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); logger.info("[POST] Receiving message from session. [sessionId={}, requestBody={}]", sessionId, requestBody); @@ -353,7 +353,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { } try { - String jsonText = jsonMapper.writeValueAsString(message); + String jsonText = FitMcpSseServerTransportProvider.this.jsonMapper.writeValueAsString(message); TextEvent textEvent = TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); @@ -382,7 +382,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { */ @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); + return FitMcpSseServerTransportProvider.this.jsonMapper.convertValue(data, typeRef); } /** 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 564fa1996..14cd78754 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 @@ -98,7 +98,7 @@ private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, boolea this.contextExtractor = contextExtractor; if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler.builder(() -> (isClosing) + this.keepAliveScheduler = KeepAliveScheduler.builder(() -> (this.isClosing) ? Flux.empty() : Flux.fromIterable(this.sessions.values())) .initialDelay(keepAliveInterval) @@ -198,13 +198,13 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo return Entity.createText(response, "Server is shutting down"); } - Object headerError = validateGetAcceptHeaders(request, response); + Object headerError = this.validateGetAcceptHeaders(request, response); if (headerError != null) { return headerError; } // Get session ID and session - Object sessionError = validateRequestSessionId(request, response); + Object sessionError = this.validateRequestSessionId(request, response); if (sessionError != null) { return sessionError; } @@ -220,9 +220,9 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo // Handle building SSE, and check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { - handleReplaySseRequest(request, transportContext, sessionId, session, sessionTransport, emitter); + FitMcpStreamableServerTransportProvider.this.handleReplaySseRequest(request, transportContext, sessionId, session, sessionTransport, emitter); } else { - handleEstablishSseRequest(sessionId, session, sessionTransport, emitter); + FitMcpStreamableServerTransportProvider.this.handleEstablishSseRequest(sessionId, session, sessionTransport, emitter); } }); } catch (Exception e) { @@ -245,7 +245,7 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); return Entity.createText(response, "Server is shutting down"); } - Object headerError = validatePostAcceptHeaders(request, response); + Object headerError = this.validatePostAcceptHeaders(request, response); if (headerError != null) { return headerError; } @@ -253,15 +253,15 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp McpTransportContext transportContext = this.contextExtractor.extract(request); try { String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, requestBody); + McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); // Handle JSONRPCMessage if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method() .equals(McpSchema.METHOD_INITIALIZE)) { logger.info("[POST] Handling initialize method. [requestBody={}]", requestBody); - return handleInitializeRequest(request, response, jsonrpcRequest); + return this.handleInitializeRequest(request, response, jsonrpcRequest); } else { - return handleJsonRpcMessage(message, request, requestBody, transportContext, response); + return this.handleJsonRpcMessage(message, request, requestBody, transportContext, response); } } catch (IllegalArgumentException | IOException e) { logger.error("[POST] Failed to deserialize message. [error={}]", e.getMessage(), e); @@ -295,7 +295,7 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe } // Get session ID and session - Object sessionError = validateRequestSessionId(request, response); + Object sessionError = this.validateRequestSessionId(request, response); if (sessionError != null) { return sessionError; } @@ -484,7 +484,7 @@ public void onFailed(Exception cause) { private Object handleInitializeRequest(HttpClassicServerRequest request, HttpClassicServerResponse response, McpSchema.JSONRPCRequest jsonrpcRequest) { McpSchema.InitializeRequest initializeRequest = - jsonMapper.convertValue(jsonrpcRequest.params(), new TypeRef() {}); + this.jsonMapper.convertValue(jsonrpcRequest.params(), new TypeRef() {}); McpStreamableServerSession.McpStreamableServerSessionInit init = this.sessionFactory.startSession(initializeRequest); this.sessions.put(init.session().getId(), init.session()); @@ -519,7 +519,7 @@ private Object handleInitializeRequest(HttpClassicServerRequest request, HttpCla private Object handleJsonRpcMessage(McpSchema.JSONRPCMessage message, HttpClassicServerRequest request, String requestBody, McpTransportContext transportContext, HttpClassicServerResponse response) { // Get session ID and session - Object sessionError = validateRequestSessionId(request, response); + Object sessionError = this.validateRequestSessionId(request, response); if (sessionError != null) { return sessionError; } @@ -528,13 +528,13 @@ private Object handleJsonRpcMessage(McpSchema.JSONRPCMessage message, HttpClassi logger.info("[POST] Receiving message from session. [sessionId={}, requestBody={}]", sessionId, requestBody); if (message instanceof McpSchema.JSONRPCResponse jsonrpcResponse) { - handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); + this.handleJsonRpcResponse(jsonrpcResponse, session, transportContext, response); return null; } else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) { - handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); + this.handleJsonRpcNotification(jsonrpcNotification, session, transportContext, response); return null; } else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) { - return handleJsonRpcRequest(jsonrpcRequest, session, sessionId, transportContext, response); + return this.handleJsonRpcRequest(jsonrpcRequest, session, sessionId, transportContext, response); } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, @@ -699,7 +699,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId return; } - String jsonText = jsonMapper.writeValueAsString(message); + String jsonText = FitMcpStreamableServerTransportProvider.this.jsonMapper.writeValueAsString(message); TextEvent textEvent = TextEvent.custom().id(this.sessionId).event(Event.MESSAGE.code()).data(jsonText).build(); this.emitter.emit(textEvent); @@ -736,7 +736,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message, String messageId */ @Override public T unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); + return FitMcpStreamableServerTransportProvider.this.jsonMapper.convertValue(data, typeRef); } /** From e6f4051bc3639bf6c4670d1cf185d6acc243c8c3 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, 17 Nov 2025 11:32:17 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/server/config/McpSseServerConfig.java | 2 +- .../config/McpStreamableServerConfig.java | 2 +- .../mcp/server/support/DefaultMcpServer.java | 31 +++----- .../FitMcpSseServerTransportProvider.java | 70 +++++++++++++------ 4 files changed, 60 insertions(+), 45 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index 66155c306..a02999af9 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -18,7 +18,7 @@ import java.time.Duration; /** - * Mcp Server Bean implemented with MCP SDK. + * MCP SSE Server Bean implemented with MCP SDK. * * @author 黄可欣 * @since 2025-11-10 diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index 2ff3c5685..1c8d65779 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -18,7 +18,7 @@ import java.time.Duration; /** - * Mcp Server Bean implemented with MCP SDK. + * MCP Streamable Server Bean implemented with MCP SDK. * * @author 黄可欣 * @since 2025-10-22 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 index 6ec8c67f3..ae9196153 100644 --- 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 @@ -49,8 +49,9 @@ public class DefaultMcpServer implements McpServer, ToolChangedObserver { /** * 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. + * @param toolExecuteService The service used to execute tools when handling tool call requests + * @param mcpSyncSseServer The MCP sync server for SSE transport + * @param mcpSyncStreamableServer The MCP sync server for Streamable transport */ public DefaultMcpServer(ToolExecuteService toolExecuteService, @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncSseServer, @@ -122,17 +123,11 @@ public void onToolRemoved(String name) { /** * 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}. + * @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 configured {@link McpServerFeatures.SyncToolSpecification} */ private McpServerFeatures.SyncToolSpecification createToolSpecification(String name, String description, Map parameters) { @@ -152,16 +147,10 @@ private McpServerFeatures.SyncToolSpecification createToolSpecification(String n /** * 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. + * @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 { diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index c28fc9211..92cab0217 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -55,9 +55,6 @@ public class FitMcpSseServerTransportProvider implements McpServerTransportProvi private static final Logger logger = Logger.get(FitMcpSseServerTransportProvider.class); private static final String MESSAGE_ENDPOINT = "/mcp/message"; private static final String SSE_ENDPOINT = "/mcp/sse"; - /** - * Event type for sending the message endpoint URI to clients. - */ public static final String ENDPOINT_EVENT_TYPE = "endpoint"; private final McpJsonMapper jsonMapper; @@ -94,11 +91,21 @@ private FitMcpSseServerTransportProvider(McpJsonMapper jsonMapper, Duration keep } } + /** + * Returns the list of supported MCP protocol versions. + * + * @return A list of supported protocol version strings + */ @Override public List protocolVersions() { return List.of(ProtocolVersions.MCP_2024_11_05); } + /** + * Sets the session factory used to create new MCP server sessions. + * + * @param sessionFactory The factory for creating server sessions + */ @Override public void setSessionFactory(McpServerSession.Factory sessionFactory) { this.sessionFactory = sessionFactory; @@ -162,15 +169,15 @@ public Mono closeGracefully() { * establishing an SSE connection. This method: *
    *
  • Generates a unique session ID
  • - *
  • Creates a new session with a FitMcpSessionTransport
  • - *
  • Sends an initial endpoint event to inform the client where to send - * messages
  • + *
  • Creates a new session with a {@link FitSseMcpSessionTransport}
  • + *
  • Sends an initial endpoint event to inform the client where to send messages
  • *
  • Maintains the session in the sessions map
  • *
* * @param request The incoming server request - * @return A ServerResponse configured for SSE communication, or an error response if - * the server is shutting down or the connection fails + * @param response The HTTP response for SSE communication + * @return A {@link Choir}{@code <}{@link TextEvent}{@code >} object for SSE streaming, + * or an error response if the server is shutting down or the connection fails */ @GetMapping(path = SSE_ENDPOINT) public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicServerResponse response) { @@ -212,14 +219,16 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS /** * Handles incoming JSON-RPC messages from clients. This method: *
    + *
  • Validates the session ID from the request parameter
  • *
  • Deserializes the request body into a JSON-RPC message
  • *
  • Processes the message through the session's handle method
  • *
  • Returns appropriate HTTP responses based on the processing result
  • *
* * @param request The incoming server request containing the JSON-RPC message - * @return A ServerResponse indicating success (200 OK) or appropriate error status - * with error details in case of failures + * @param response The HTTP response to set status code and return data + * @param sessionId The session ID from the request parameter + * @return An error {@link Entity} if validation fails, or {@code null} on success */ @PostMapping(path = MESSAGE_ENDPOINT) public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerResponse response, @@ -258,6 +267,14 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR } } + /** + * Adds an observer to the SSE emitter to handle connection lifecycle events. + * The observer removes the session from the sessions map when the connection + * completes or fails. + * + * @param emitter The SSE emitter to observe + * @param sessionId The session ID associated with this emitter + */ private void addEmitterObserver(Emitter emitter, String sessionId) { emitter.observe(new Emitter.Observer() { @Override @@ -307,8 +324,13 @@ private Object validateRequestSessionId(String sessionId, HttpClassicServerRespo } /** - * Implementation of McpServerTransport for WebMVC SSE sessions. This class handles - * the transport-level communication for a specific client session. + * Implementation of {@link McpServerTransport} for FIT SSE sessions. + * This class handles the transport-level communication for a specific client session. + * + *

+ * This class is thread-safe and uses a {@link ReentrantLock} to synchronize access to the + * underlying SSE emitter to prevent race conditions when multiple threads attempt to + * send messages concurrently. */ private class FitSseMcpSessionTransport implements McpServerTransport { private final String sessionId; @@ -316,16 +338,17 @@ private class FitSseMcpSessionTransport implements McpServerTransport { private final HttpClassicServerResponse response; /** - * Lock to ensure thread-safe access to the SSE builder when sending messages. + * Lock to ensure thread-safe access to the SSE emitter when sending messages. * This prevents concurrent modifications that could lead to corrupted SSE events. */ private final ReentrantLock sseBuilderLock = new ReentrantLock(); /** - * Creates a new session transport with the specified ID and SSE builder. + * Creates a new session transport with the specified ID and SSE emitter. * * @param sessionId The unique identifier for this session - * @param emitter The emitter for sending events + * @param emitter The emitter for sending SSE events to the client + * @param response The HTTP response for checking connection status */ FitSseMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { this.sessionId = sessionId; @@ -336,6 +359,8 @@ private class FitSseMcpSessionTransport implements McpServerTransport { /** * Sends a JSON-RPC message to the client through the SSE connection. + * The message is serialized to JSON and sent as an SSE event with type "message". + * This method is thread-safe and checks if the connection is still active before sending. * * @param message The JSON-RPC message to send * @return A Mono that completes when the message has been sent @@ -343,7 +368,7 @@ private class FitSseMcpSessionTransport implements McpServerTransport { @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.fromRunnable(() -> { - sseBuilderLock.lock(); + this.sseBuilderLock.lock(); // Check if connection is still active before sending if (!this.response.isActive()) { logger.warn("[SSE] Connection inactive detected while sending message. [sessionId={}]", @@ -367,7 +392,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { e); this.emitter.fail(e); } finally { - sseBuilderLock.unlock(); + this.sseBuilderLock.unlock(); } }); } @@ -397,20 +422,21 @@ public Mono closeGracefully() { /** * Closes the transport immediately. + * Completes the SSE emitter and releases any associated resources. */ @Override public void close() { - sseBuilderLock.lock(); - logger.debug("[SSE] Closing session transport. [sessionId={}]", sessionId); + this.sseBuilderLock.lock(); + logger.debug("[SSE] Closing session transport. [sessionId={}]", this.sessionId); try { this.emitter.complete(); - logger.info("[SSE] Closed SSE builder successfully. [sessionId={}]", sessionId); + logger.info("[SSE] Closed SSE builder successfully. [sessionId={}]", this.sessionId); } catch (Exception e) { logger.warn("[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", - sessionId, + this.sessionId, e.getMessage()); } finally { - sseBuilderLock.unlock(); + this.sseBuilderLock.unlock(); } } From 13772760798d458270be06892f40e6bd1d763e70 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, 17 Nov 2025 11:36:19 +0800 Subject: [PATCH 09/18] =?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 | 84 +++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/README.md b/framework/fel/java/plugins/tool-mcp-server/README.md index b9222ebdd..f9c924870 100644 --- a/framework/fel/java/plugins/tool-mcp-server/README.md +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -1,17 +1,50 @@ -# FitMcpStreamableServerTransportProvider类维护文档 +# MCP Server 插件维护文档 ## 文档概述 -本文档用于记录 `FitMcpStreamableServerTransportProvider` 类的设计、实现细节以及维护更新指南。该类是基于 MCP SDK 中的 -`HttpServletStreamableServerTransportProvider` 类改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 - -**原始参考类**: MCP SDK 中的 `HttpServletStreamableServerTransportProvider` +本文档用于记录 MCP Server 插件的设计、实现细节以及维护更新指南。该插件基于 MCP SDK 改造而来,用于在 FIT 框架中提供 MCP(Model Context Protocol)服务端的传输层实现。 **创建时间**: 2025-11-04 --- -## 类的作用和职责 +## 架构概览 + +### DefaultMcpServer 的双 Bean 管理 + +`DefaultMcpServer` 是 MCP Server 的核心实现类,它同时管理两个 MCP 同步服务器 Bean: + +1. **McpSyncSseServer** - 用于 SSE (Server-Sent Events) 传输 +2. **McpSyncStreamableServer** - 用于 Streamable 传输 + +这两个 Bean 分别由 `McpSseServerConfig` 和 `McpStreamableServerConfig` 配置类创建,并通过 `@Fit(alias = "...")` 注解进行区分注入: + +```java +// McpSseServerConfig.java +@Bean("McpSyncSseServer") +public McpSyncServer mcpSyncSseServer(...) { ... } + +// McpStreamableServerConfig.java +@Bean("McpSyncStreamableServer") +public McpSyncServer mcpSyncStreamableServer(...) { ... } + +// DefaultMcpServer.java +public DefaultMcpServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncSseServer, + @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncStreamableServer) { + ... +} +``` + +`DefaultMcpServer` 实现了 `McpServer` 和 `ToolChangedObserver` 接口,负责: +- 统一管理两个传输类型的工具注册和移除 +- 确保两个 MCP 同步服务器保持工具列表同步 +- 处理工具执行请求并返回结果 +- 通知工具变更观察者 + +--- + +## FitMcpStreamableServerTransportProvider 类的作用和职责 `FitMcpStreamableServerTransportProvider` 是 MCP 服务端传输层的核心实现类,负责: @@ -307,6 +340,45 @@ if(!this.response.isActive()){ } ``` +--- + +## FitMcpSseServerTransportProvider 简要说明 + +`FitMcpSseServerTransportProvider` 是基于 MCP SDK 中的 `HttpServletSseServerTransportProvider` 改造而来的 FIT 框架实现,用于提供 MCP SSE 传输层。 + +### 与 Streamable 的主要区别 + +`FitMcpSseServerTransportProvider` 的实现与 `FitMcpStreamableServerTransportProvider` 非常相似,主要区别在于: + +1. **端点路径**: + - SSE: `/mcp/sse` (GET) 和 `/mcp/message` (POST) + - Streamable: `/mcp/streamable` (GET/POST/DELETE) + +2. **协议版本支持**: + - SSE: 仅支持 `MCP_2024_11_05` + - Streamable: 支持 `MCP_2024_11_05`、`MCP_2025_03_26`、`MCP_2025_06_18` + +3. **请求处理**: + - SSE: GET 请求用于建立 SSE 连接,POST 请求用于发送 JSON-RPC 消息 + - Streamable: GET 请求用于建立 SSE 连接或重放消息,POST 请求处理初始化和其他 JSON-RPC 消息,DELETE 请求用于删除会话 + +4. **会话管理**: + - SSE: 使用 `McpServerSession`,会话通过 GET 请求建立 + - Streamable: 使用 `McpStreamableServerSession`,会话通过 POST 初始化请求建立 + +### 核心改造点 + +与 Streamable 版本类似,SSE 版本也进行了以下 FIT 框架改造: + +- 使用 `HttpClassicServerRequest` 和 `HttpClassicServerResponse` 替代 Servlet API +- 使用 `Choir` 和 `Emitter` 实现 SSE 事件流 +- 使用 FIT 的 HTTP 注解 (`@GetMapping`, `@PostMapping`) 处理请求 +- 使用 `Entity.createText()` 和 `Entity.createObject()` 创建响应 + +详细的实现细节可以参考 `FitMcpStreamableServerTransportProvider` 的相关章节。 + +--- + ## 参考资源 ### MCP 协议文档 From eda9e1eb0e6f09450a228e28da9f1dfd6d49ed2b 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, 17 Nov 2025 16:11:20 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9notnull=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FitMcpSseServerTransportProvider.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 92cab0217..288c57886 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -16,7 +16,6 @@ import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; -import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.KeepAliveScheduler; import modelengine.fel.tool.mcp.entity.Event; import modelengine.fit.http.annotation.GetMapping; @@ -60,7 +59,7 @@ public class FitMcpSseServerTransportProvider implements McpServerTransportProvi private final McpJsonMapper jsonMapper; private McpServerSession.Factory sessionFactory; private final Map sessions = new ConcurrentHashMap(); - private McpTransportContextExtractor contextExtractor; + private final McpTransportContextExtractor contextExtractor; private volatile boolean isClosing = false; private KeepAliveScheduler keepAliveScheduler; @@ -76,8 +75,8 @@ public class FitMcpSseServerTransportProvider implements McpServerTransportProvi */ private FitMcpSseServerTransportProvider(McpJsonMapper jsonMapper, Duration keepAliveInterval, McpTransportContextExtractor contextExtractor) { - Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); - Assert.notNull(contextExtractor, "Context extractor must not be null"); + Validation.notNull(jsonMapper, "McpJsonMapper must not be null"); + Validation.notNull(contextExtractor, "Context extractor must not be null"); this.jsonMapper = jsonMapper; this.contextExtractor = contextExtractor; if (keepAliveInterval != null) { @@ -195,7 +194,6 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS new FitSseMcpSessionTransport(sessionId, emitter, response); McpServerSession session = this.sessionFactory.create(sessionTransport); this.sessions.put(sessionId, session); - try { String initData = MESSAGE_ENDPOINT + "?sessionId=" + sessionId; TextEvent textEvent = @@ -241,10 +239,9 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR if (sessionError != null) { return sessionError; } - McpServerSession session = this.sessions.get(sessionId); try { - final McpTransportContext transportContext = this.contextExtractor.extract(request); + McpTransportContext transportContext = this.contextExtractor.extract(request); String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); @@ -471,7 +468,7 @@ public static class Builder { * @return This builder instance for method chaining */ public Builder jsonMapper(McpJsonMapper jsonMapper) { - Assert.notNull(jsonMapper, "McpJsonMapper must not be null"); + Validation.notNull(jsonMapper, "McpJsonMapper must not be null"); this.jsonMapper = jsonMapper; return this; } @@ -501,7 +498,7 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { * @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; } From add8c3f52d027d0b324d1a290b1a33a1f58da04d 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, 19 Nov 2025 09:36:34 +0800 Subject: [PATCH 11/18] =?UTF-8?q?keepAliveInterval=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/config/McpSseServerConfig.java | 5 +++-- .../tool/mcp/server/config/McpStreamableServerConfig.java | 5 +++-- .../tool-mcp-server/src/main/resources/application.yml | 3 ++- .../fel/tool/mcp/server/support/DefaultMcpServerTest.java | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index a02999af9..689c0d7d6 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -26,10 +26,11 @@ @Component public class McpSseServerConfig { @Bean - public FitMcpSseServerTransportProvider fitMcpSseServerTransportProvider() { + public FitMcpSseServerTransportProvider fitMcpSseServerTransportProvider( + @Value("${mcp.server.keep-alive-interval-seconds}") int keepAliveIntervalSeconds) { return FitMcpSseServerTransportProvider.builder() .jsonMapper(McpJsonMapper.getDefault()) - .keepAliveInterval(Duration.ofSeconds(30)) + .keepAliveInterval(Duration.ofSeconds(keepAliveIntervalSeconds)) .build(); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index 1c8d65779..1703f258a 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -26,10 +26,11 @@ @Component public class McpStreamableServerConfig { @Bean - public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider() { + public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider( + @Value("${mcp.server.keep-alive-interval-seconds}") int keepAliveIntervalSeconds) { return FitMcpStreamableServerTransportProvider.builder() .jsonMapper(McpJsonMapper.getDefault()) - .keepAliveInterval(Duration.ofSeconds(30)) + .keepAliveInterval(Duration.ofSeconds(keepAliveIntervalSeconds)) .build(); } 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 d3ac184e8..769a3973a 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,5 @@ fit: mcp: server: request: - timeout-seconds: 60 \ No newline at end of file + timeout-seconds: 60 + keep-alive-interval-seconds: 30 \ 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/DefaultMcpServerTest.java index 8368ad71c..770d13428 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 @@ -45,10 +45,10 @@ public class DefaultMcpServerTest { void setup() { this.toolExecuteService = mock(ToolExecuteService.class); McpSseServerConfig sseConfig = new McpSseServerConfig(); - this.mcpSyncSseServer = sseConfig.mcpSyncSseServer(sseConfig.fitMcpSseServerTransportProvider(), 10); + this.mcpSyncSseServer = sseConfig.mcpSyncSseServer(sseConfig.fitMcpSseServerTransportProvider(30), 10); McpStreamableServerConfig streamableConfig = new McpStreamableServerConfig(); this.mcpStreamableSyncServer = - streamableConfig.mcpSyncStreamableServer(streamableConfig.fitMcpStreamableServerTransportProvider(), + streamableConfig.mcpSyncStreamableServer(streamableConfig.fitMcpStreamableServerTransportProvider(30), 10); } From b5b764ffe0d8cf25a608e4012bf24dd0a8743ffc 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, 19 Nov 2025 16:45:34 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E6=8A=BD=E5=8F=96FitMcpServerTransportPr?= =?UTF-8?q?ovider=E5=9F=BA=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/FitMcpServerTransportProvider.java | 343 ++++++++++++++++++ .../FitMcpSseServerTransportProvider.java | 246 +++---------- ...tMcpStreamableServerTransportProvider.java | 313 ++++------------ 3 files changed, 461 insertions(+), 441 deletions(-) create mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java new file mode 100644 index 000000000..03df8f7e4 --- /dev/null +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java @@ -0,0 +1,343 @@ +/*--------------------------------------------------------------------------------------------- + * 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 io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpTransportContextExtractor; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.KeepAliveScheduler; +import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fit.http.entity.Entity; +import modelengine.fit.http.entity.TextEvent; +import modelengine.fit.http.protocol.HttpResponseStatus; +import modelengine.fit.http.server.HttpClassicServerRequest; +import modelengine.fit.http.server.HttpClassicServerResponse; +import modelengine.fitframework.flowable.Emitter; +import modelengine.fitframework.inspection.Validation; +import modelengine.fitframework.log.Logger; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Abstract base class for FIT MCP Server Transport Providers. + * This class provides common functionality for both SSE and Streamable transport implementations. + * + * @param The session type + * @author 黄可欣 + * @since 2025-09-30 + */ +public abstract class FitMcpServerTransportProvider { + protected final McpJsonMapper jsonMapper; + protected final McpTransportContextExtractor contextExtractor; + protected KeepAliveScheduler keepAliveScheduler; + + protected volatile boolean isClosing = false; + protected final Map sessions = new ConcurrentHashMap<>(); + + /** + * Constructs a new FitMcpServerTransportProvider instance. + * + * @param jsonMapper The JSON mapper for serialization/deserialization + * @param contextExtractor The context extractor for HTTP requests + * @param keepAliveInterval The interval for keep-alive messages, or null to disable + */ + protected FitMcpServerTransportProvider(McpJsonMapper jsonMapper, + McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { + Validation.notNull(jsonMapper, "McpJsonMapper must not be null"); + Validation.notNull(contextExtractor, "Context extractor must not be null"); + + this.jsonMapper = jsonMapper; + this.contextExtractor = contextExtractor; + if (keepAliveInterval != null) { + this.initKeepAliveScheduler(keepAliveInterval); + } + } + + /** + * Gets static logger instance for this transport provider. + * + * @return The logger instance + */ + protected abstract Logger getLogger(); + + /** + * Initializes the keep-alive scheduler with the specified interval. + * + * @param keepAliveInterval The interval for keep-alive messages + */ + protected abstract void initKeepAliveScheduler(Duration keepAliveInterval); + + /** + * Gets the session ID from a session object. + * + * @param session The session object + * @return The session ID + */ + protected abstract String getSessionId(S session); + + /** + * Closes a session gracefully. + * + * @param session The session to close + * @return A Mono that completes when the session is closed + */ + protected abstract Mono closeSession(S session); + + /** + * Sends a notification to a specific session. + * + * @param session The session to send to + * @param method The notification method name + * @param params The notification parameters + * @return A Mono that completes when the notification is sent + */ + protected abstract Mono sendNotificationToSession(S session, String method, Object params); + + /** + * Broadcasts a notification to all connected clients. + * 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 + */ + public Mono notifyClients(String method, Object params) { + if (this.sessions.isEmpty()) { + this.getLogger().debug("No active sessions to broadcast message to"); + return Mono.empty(); + } + + this.getLogger().debug("Attempting to broadcast message. [activeSessions={}]", this.sessions.size()); + + return Mono.fromRunnable(() -> this.sessions.values().parallelStream().forEach(session -> { + try { + this.sendNotificationToSession(session, method, params).block(); + } catch (Exception e) { + this.getLogger() + .error("Failed to send message to session. [sessionId={}, error={}]", + this.getSessionId(session), + e.getMessage(), + e); + } + })); + } + + /** + * Initiates a graceful shutdown of the transport. + * + * @return A Mono that completes when all cleanup operations are finished + */ + public Mono closeGracefully() { + this.isClosing = true; + this.getLogger().debug("Initiating graceful shutdown. [activeSessions={}]", this.sessions.size()); + + return Mono.fromRunnable(() -> { + this.sessions.values().parallelStream().forEach(session -> { + try { + this.closeSession(session).block(); + } catch (Exception e) { + this.getLogger() + .error("Failed to close session. [sessionId={}, error={}]", + this.getSessionId(session), + e.getMessage(), + e); + } + }); + + this.getLogger().debug("Graceful shutdown completed"); + this.sessions.clear(); + if (this.keepAliveScheduler != null) { + this.keepAliveScheduler.shutdown(); + } + }); + } + + /** + * Creates a response indicating the server is shutting down. + * + * @param response The HTTP response + * @return An Entity with the shutdown message + */ + protected Object createShuttingDownResponse(HttpClassicServerResponse response) { + response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); + return Entity.createText(response, "Server is shutting down"); + } + + /** + * Validates that a session exists for the given session ID. + * + * @param sessionId The session ID to validate + * @param response The HTTP response to set status code if validation fails + * @return An error Entity if validation fails, null if validation succeeds + */ + protected Object validateSessionExists(String sessionId, HttpClassicServerResponse response) { + if (sessionId == null || sessionId.isEmpty()) { + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + return Entity.createText(response, "Session ID missing"); + } + 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; + } + + /** + * Abstract base class for session transport implementations. + * Provides common functionality for sending messages over SSE connections. + */ + protected abstract class AbstractFitMcpSessionTransport { + protected final String sessionId; + protected final Emitter emitter; + protected final HttpClassicServerResponse response; + + protected final ReentrantLock lock = new ReentrantLock(); + protected volatile boolean closed = false; + + /** + * Creates a new session transport. + * + * @param sessionId The unique identifier for this session + * @param emitter The emitter for sending SSE events + * @param response The HTTP response for checking connection status + */ + protected AbstractFitMcpSessionTransport(String sessionId, Emitter emitter, + HttpClassicServerResponse response) { + this.sessionId = sessionId; + this.emitter = emitter; + this.response = response; + FitMcpServerTransportProvider.this.getLogger() + .info("[SSE] Building SSE emitter. [sessionId={}]", sessionId); + } + + /** + * Sends a JSON-RPC message to the client through the SSE connection. + * This method is thread-safe and checks if the connection is still active before sending. + * + * @param message The JSON-RPC message to send + * @return A Mono that completes when the message has been sent + */ + protected Mono doSendMessage(McpSchema.JSONRPCMessage message, String messageId) { + return Mono.fromRunnable(() -> { + if (this.closed) { + FitMcpServerTransportProvider.this.getLogger() + .info("[SSE] Attempted to send message to closed session. [sessionId={}]", this.sessionId); + return; + } + this.lock.lock(); + try { + if (this.closed) { + FitMcpServerTransportProvider.this.getLogger() + .info("[SSE] Session was closed during message send attempt. [sessionId={}]", + this.sessionId); + return; + } + + if (!this.response.isActive()) { + FitMcpServerTransportProvider.this.getLogger() + .warn("[SSE] Connection inactive detected while sending message. [sessionId={}]", + this.sessionId); + this.doClose(); + return; + } + + String jsonText = FitMcpServerTransportProvider.this.jsonMapper.writeValueAsString(message); + TextEvent textEvent = TextEvent.custom() + .id(messageId != null ? messageId : this.sessionId) + .event(Event.MESSAGE.code()) + .data(jsonText) + .build(); + this.emitter.emit(textEvent); + + FitMcpServerTransportProvider.this.getLogger() + .info("[SSE] Sending message to session. [sessionId={}, eventId={}, jsonText={}]", + this.sessionId, + messageId != null ? messageId : this.sessionId, + jsonText); + } catch (Exception e) { + FitMcpServerTransportProvider.this.getLogger() + .error("[SSE] Failed to send message to session. [sessionId={}, error={}]", + this.sessionId, + e.getMessage(), + e); + try { + this.emitter.fail(e); + } catch (Exception errorException) { + FitMcpServerTransportProvider.this.getLogger() + .error("[SSE] Failed to send error to SSE builder. [sessionId={}, error={}]", + this.sessionId, + errorException.getMessage(), + errorException); + } + } finally { + this.lock.unlock(); + } + }); + } + + /** + * Converts data from one type to another using the configured McpJsonMapper. + * + * @param data The source data object to convert + * @param typeRef The target type reference + * @param The target type + * @return The converted object of type T + */ + public T unmarshalFrom(Object data, TypeRef typeRef) { + return FitMcpServerTransportProvider.this.jsonMapper.convertValue(data, typeRef); + } + + /** + * Initiates a graceful shutdown of the transport. + * + * @return A Mono that completes when the shutdown is complete + */ + public Mono closeGracefully() { + return Mono.fromRunnable(this::doClose); + } + + /** + * Closes the transport immediately. + * Completes the SSE emitter and releases any associated resources. + */ + protected void doClose() { + this.lock.lock(); + try { + if (this.closed) { + FitMcpServerTransportProvider.this.getLogger() + .info("[SSE] Session transport already closed. [sessionId={}]", this.sessionId); + return; + } + + this.closed = true; + FitMcpServerTransportProvider.this.getLogger() + .debug("[SSE] Closing session transport. [sessionId={}]", this.sessionId); + + this.emitter.complete(); + FitMcpServerTransportProvider.this.getLogger() + .info("[SSE] Closed SSE builder successfully. [sessionId={}]", this.sessionId); + } catch (Exception e) { + FitMcpServerTransportProvider.this.getLogger() + .warn("[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", + this.sessionId, + e.getMessage()); + } finally { + this.lock.unlock(); + } + } + } +} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 288c57886..7963ee236 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -8,7 +8,6 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; -import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.server.McpTransportContextExtractor; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; @@ -17,7 +16,7 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.KeepAliveScheduler; -import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fel.tool.mcp.server.FitMcpServerTransportProvider; import modelengine.fit.http.annotation.GetMapping; import modelengine.fit.http.annotation.PostMapping; import modelengine.fit.http.annotation.RequestParam; @@ -37,10 +36,7 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; -import java.util.Map; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; /** * The default implementation of {@link McpServerTransportProvider}. @@ -50,18 +46,13 @@ * @author 黄可欣 * @since 2025-09-30 */ -public class FitMcpSseServerTransportProvider implements McpServerTransportProvider { +public class FitMcpSseServerTransportProvider extends FitMcpServerTransportProvider + implements McpServerTransportProvider { private static final Logger logger = Logger.get(FitMcpSseServerTransportProvider.class); private static final String MESSAGE_ENDPOINT = "/mcp/message"; private static final String SSE_ENDPOINT = "/mcp/sse"; public static final String ENDPOINT_EVENT_TYPE = "endpoint"; - - private final McpJsonMapper jsonMapper; private McpServerSession.Factory sessionFactory; - private final Map sessions = new ConcurrentHashMap(); - private final McpTransportContextExtractor contextExtractor; - private volatile boolean isClosing = false; - private KeepAliveScheduler keepAliveScheduler; /** * Constructs a new FitMcpSseServerTransportProvider instance. @@ -75,19 +66,38 @@ public class FitMcpSseServerTransportProvider implements McpServerTransportProvi */ private FitMcpSseServerTransportProvider(McpJsonMapper jsonMapper, Duration keepAliveInterval, McpTransportContextExtractor contextExtractor) { - Validation.notNull(jsonMapper, "McpJsonMapper must not be null"); - Validation.notNull(contextExtractor, "Context extractor must not be null"); - this.jsonMapper = jsonMapper; - this.contextExtractor = contextExtractor; - if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler.builder(() -> this.isClosing - ? Flux.empty() - : Flux.fromIterable(this.sessions.values())) - .initialDelay(keepAliveInterval) - .interval(keepAliveInterval) - .build(); - this.keepAliveScheduler.start(); - } + super(jsonMapper, contextExtractor, keepAliveInterval); + } + + @Override + protected Logger getLogger() { + return logger; + } + + @Override + protected void initKeepAliveScheduler(Duration keepAliveInterval) { + this.keepAliveScheduler = KeepAliveScheduler.builder(() -> this.isClosing + ? Flux.empty() + : Flux.fromIterable(this.sessions.values())) + .initialDelay(keepAliveInterval) + .interval(keepAliveInterval) + .build(); + this.keepAliveScheduler.start(); + } + + @Override + protected String getSessionId(McpServerSession session) { + return session.getId(); + } + + @Override + protected Mono closeSession(McpServerSession session) { + return session.closeGracefully(); + } + + @Override + protected Mono sendNotificationToSession(McpServerSession session, String method, Object params) { + return session.sendNotification(method, params); } /** @@ -110,59 +120,6 @@ public void setSessionFactory(McpServerSession.Factory sessionFactory) { this.sessionFactory = sessionFactory; } - /** - * Broadcasts a notification to all connected clients through their SSE connections. - * The message is serialized to JSON and sent as an SSE event with type "message". 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 (this.sessions.isEmpty()) { - logger.debug("No active sessions to broadcast message to"); - return Mono.empty(); - } - - logger.debug("Attempting to broadcast message. [activeSessions={}]", this.sessions.size()); - - return Flux.fromIterable(this.sessions.values()) - .flatMap(session -> session.sendNotification(method, params) - .doOnError(e -> logger.error("Failed to send message to session. [sessionId={}, error={}]", - session.getId(), - e.getMessage(), - e)) - .onErrorComplete()) - .then(); - } - - /** - * Initiates a graceful shutdown of the transport. This method: - *

    - *
  • Sets the closing flag to prevent new connections
  • - *
  • Closes all active SSE connections
  • - *
  • Removes all session records
  • - *
- * - * @return A Mono that completes when all cleanup operations are finished - */ - @Override - public Mono closeGracefully() { - return Flux.fromIterable(this.sessions.values()).doFirst(() -> { - this.isClosing = true; - logger.debug("Initiating graceful shutdown. [activeSessions={}]", this.sessions.size()); - }).flatMap(McpServerSession::closeGracefully).then().doOnSuccess(v -> { - logger.debug("Graceful shutdown completed"); - this.sessions.clear(); - if (this.keepAliveScheduler != null) { - this.keepAliveScheduler.shutdown(); - } - }); - } - /** * Handles new SSE connection requests from clients by creating a new session and * establishing an SSE connection. This method: @@ -181,8 +138,7 @@ public Mono closeGracefully() { @GetMapping(path = SSE_ENDPOINT) public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - return Entity.createText(response, "Server is shutting down"); + return this.createShuttingDownResponse(response); } String sessionId = UUID.randomUUID().toString(); @@ -232,8 +188,7 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerResponse response, @RequestParam("sessionId") String sessionId) { if (this.isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - return Entity.createText(response, "Server is shutting down"); + return this.createShuttingDownResponse(response); } Object sessionError = this.validateRequestSessionId(sessionId, response); if (sessionError != null) { @@ -282,13 +237,16 @@ public void onEmittedData(TextEvent data) { @Override public void onCompleted() { FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); - logger.info("[SSE] Completed SSE emitting and closed session successfully. [sessionId={}]", sessionId); + FitMcpSseServerTransportProvider.logger.info( + "[SSE] Completed SSE emitting and closed session successfully. [sessionId={}]", + sessionId); } @Override public void onFailed(Exception cause) { FitMcpSseServerTransportProvider.this.sessions.remove(sessionId); - logger.warn("[SSE] SSE failed, session closed. [sessionId={}, cause={}]", + FitMcpSseServerTransportProvider.logger.warn( + "[SSE] SSE failed, session closed. [sessionId={}, cause={}]", sessionId, cause.getMessage()); } @@ -310,14 +268,7 @@ private Object validateRequestSessionId(String sessionId, HttpClassicServerRespo response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return Entity.createText(response, "Session ID missing in message endpoint"); } - 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; + return this.validateSessionExists(sessionId, response); } /** @@ -325,126 +276,27 @@ private Object validateRequestSessionId(String sessionId, HttpClassicServerRespo * This class handles the transport-level communication for a specific client session. * *

- * This class is thread-safe and uses a {@link ReentrantLock} to synchronize access to the + * This class is thread-safe and uses a {@link java.util.concurrent.locks.ReentrantLock} to synchronize access to + * the * underlying SSE emitter to prevent race conditions when multiple threads attempt to * send messages concurrently. */ - private class FitSseMcpSessionTransport implements McpServerTransport { - private final String sessionId; - private final Emitter emitter; - private final HttpClassicServerResponse response; - - /** - * Lock to ensure thread-safe access to the SSE emitter when sending messages. - * This prevents concurrent modifications that could lead to corrupted SSE events. - */ - private final ReentrantLock sseBuilderLock = new ReentrantLock(); - - /** - * Creates a new session transport with the specified ID and SSE emitter. - * - * @param sessionId The unique identifier for this session - * @param emitter The emitter for sending SSE events to the client - * @param response The HTTP response for checking connection status - */ + private class FitSseMcpSessionTransport extends AbstractFitMcpSessionTransport implements McpServerTransport { FitSseMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { - this.sessionId = sessionId; - this.emitter = emitter; - this.response = response; - logger.info("[SSE] Building SSE emitter. [sessionId={}]", sessionId); + super(sessionId, emitter, response); } - /** - * Sends a JSON-RPC message to the client through the SSE connection. - * The message is serialized to JSON and sent as an SSE event with type "message". - * This method is thread-safe and checks if the connection is still active before sending. - * - * @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 Mono.fromRunnable(() -> { - this.sseBuilderLock.lock(); - // Check if connection is still active before sending - if (!this.response.isActive()) { - logger.warn("[SSE] Connection inactive detected while sending message. [sessionId={}]", - this.sessionId); - this.close(); - return; - } - - try { - String jsonText = FitMcpSseServerTransportProvider.this.jsonMapper.writeValueAsString(message); - 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. [sessionId={}, jsonText={}]", - this.sessionId, - jsonText); - } catch (Exception e) { - logger.error("[SSE] Failed to send message to session. [sessionId={}, error={}]", - this.sessionId, - e.getMessage(), - e); - this.emitter.fail(e); - } finally { - this.sseBuilderLock.unlock(); - } - }); + return this.doSendMessage(message, null); } - /** - * Converts data from one type to another using the configured McpJsonMapper. - * - * @param data The source data object to convert - * @param typeRef The target type reference - * @param The target type - * @return The converted object of type T - */ - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return FitMcpSseServerTransportProvider.this.jsonMapper.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(FitMcpSseServerTransportProvider.FitSseMcpSessionTransport.this::close); - } - - /** - * Closes the transport immediately. - * Completes the SSE emitter and releases any associated resources. - */ @Override public void close() { - this.sseBuilderLock.lock(); - logger.debug("[SSE] Closing session transport. [sessionId={}]", this.sessionId); - try { - this.emitter.complete(); - logger.info("[SSE] Closed SSE builder successfully. [sessionId={}]", this.sessionId); - } catch (Exception e) { - logger.warn("[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", - this.sessionId, - e.getMessage()); - } finally { - this.sseBuilderLock.unlock(); - } + this.doClose(); } - } - /** - * Creates a new Builder instance for configuring and creating instances of - * FitMcpSseServerTransportProvider. - * - * @return A new Builder instance - */ public static Builder builder() { return new Builder(); } 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 14cd78754..806f270be 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 @@ -18,7 +18,7 @@ import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.KeepAliveScheduler; -import modelengine.fel.tool.mcp.entity.Event; +import modelengine.fel.tool.mcp.server.FitMcpServerTransportProvider; import modelengine.fit.http.annotation.DeleteMapping; import modelengine.fit.http.annotation.GetMapping; import modelengine.fit.http.annotation.PostMapping; @@ -40,42 +40,22 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; /** * The default implementation of {@link McpStreamableServerTransportProvider}. - * The FIT transport provider for MCP Streamable Server, according to {@code HttpServletStreamableServerTransportProvider} in MCP + * The FIT transport provider for MCP Streamable Server, according to + * {@code HttpServletStreamableServerTransportProvider} in MCP * SDK. * * @author 黄可欣 * @since 2025-09-30 */ -public class FitMcpStreamableServerTransportProvider implements McpStreamableServerTransportProvider { +public class FitMcpStreamableServerTransportProvider extends FitMcpServerTransportProvider + implements McpStreamableServerTransportProvider { private static final Logger logger = Logger.get(FitMcpStreamableServerTransportProvider.class); - private static final String MESSAGE_ENDPOINT = "/mcp/streamable"; - - /** - * Flag indicating whether DELETE requests are disallowed on the endpoint. - */ - private final boolean disallowDelete; - private final McpJsonMapper jsonMapper; - private final McpTransportContextExtractor contextExtractor; - private KeepAliveScheduler keepAliveScheduler; - private McpStreamableServerSession.Factory sessionFactory; - - /** - * Map of active client sessions, keyed by mcp-session-id. - */ - private final Map sessions = new ConcurrentHashMap<>(); - - /** - * Flag indicating if the transport is shutting down. - */ - private volatile boolean isClosing = false; + private final boolean disallowDelete; /** * Constructs a new FitMcpStreamableServerTransportProvider instance, @@ -90,98 +70,51 @@ public class FitMcpStreamableServerTransportProvider implements McpStreamableSer */ 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"); - - this.jsonMapper = jsonMapper; + super(jsonMapper, contextExtractor, keepAliveInterval); this.disallowDelete = disallowDelete; - this.contextExtractor = contextExtractor; - - if (keepAliveInterval != null) { - this.keepAliveScheduler = KeepAliveScheduler.builder(() -> (this.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, - ProtocolVersions.MCP_2025_06_18); + protected Logger getLogger() { + return logger; } @Override - public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { - this.sessionFactory = sessionFactory; + protected void initKeepAliveScheduler(Duration keepAliveInterval) { + this.keepAliveScheduler = KeepAliveScheduler.builder(() -> this.isClosing + ? Flux.empty() + : Flux.fromIterable(this.sessions.values())) + .initialDelay(keepAliveInterval) + .interval(keepAliveInterval) + .build(); + this.keepAliveScheduler.start(); } - /** - * 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 (this.sessions.isEmpty()) { - logger.debug("No active sessions to broadcast message."); - return Mono.empty(); - } - - logger.info("Attempting to broadcast message. [sessionCount={}]", this.sessions.size()); + protected String getSessionId(McpStreamableServerSession session) { + return session.getId(); + } - 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. [sessionId={}, error={}]", - session.getId(), - e.getMessage(), - e); - } - }); - }); + @Override + protected Mono closeSession(McpStreamableServerSession session) { + return session.closeGracefully(); } - /** - * 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.info("Initiating graceful shutdown. [sessionCount={}]", this.sessions.size()); + protected Mono sendNotificationToSession(McpStreamableServerSession session, String method, Object params) { + return session.sendNotification(method, params); + } - this.sessions.values().parallelStream().forEach(session -> { - try { - session.closeGracefully().block(); - } catch (Exception e) { - logger.error("Failed to close session. [sessionId={}, error={}]", - session.getId(), - e.getMessage(), - e); - } - }); + @Override + public List protocolVersions() { + return List.of(ProtocolVersions.MCP_2024_11_05, + ProtocolVersions.MCP_2025_03_26, + ProtocolVersions.MCP_2025_06_18); + } - this.sessions.clear(); - logger.info("Graceful shutdown completed."); - }).then().doOnSuccess(v -> { - if (this.keepAliveScheduler != null) { - this.keepAliveScheduler.shutdown(); - } - }); + @Override + public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { + this.sessionFactory = sessionFactory; } /** @@ -194,8 +127,7 @@ public Mono closeGracefully() { @GetMapping(path = MESSAGE_ENDPOINT) public Object handleGet(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - return Entity.createText(response, "Server is shutting down"); + return this.createShuttingDownResponse(response); } Object headerError = this.validateGetAcceptHeaders(request, response); @@ -220,9 +152,17 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo // Handle building SSE, and check if this is a replay request if (request.headers().contains(HttpHeaders.LAST_EVENT_ID)) { - FitMcpStreamableServerTransportProvider.this.handleReplaySseRequest(request, transportContext, sessionId, session, sessionTransport, emitter); + FitMcpStreamableServerTransportProvider.this.handleReplaySseRequest(request, + transportContext, + sessionId, + session, + sessionTransport, + emitter); } else { - FitMcpStreamableServerTransportProvider.this.handleEstablishSseRequest(sessionId, session, sessionTransport, emitter); + FitMcpStreamableServerTransportProvider.this.handleEstablishSseRequest(sessionId, + session, + sessionTransport, + emitter); } }); } catch (Exception e) { @@ -242,8 +182,7 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo @PostMapping(path = MESSAGE_ENDPOINT) public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - return Entity.createText(response, "Server is shutting down"); + return this.createShuttingDownResponse(response); } Object headerError = this.validatePostAcceptHeaders(request, response); if (headerError != null) { @@ -286,8 +225,7 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp @DeleteMapping(path = MESSAGE_ENDPOINT) public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerResponse response) { if (this.isClosing) { - response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - return Entity.createText(response, "Server is shutting down"); + return this.createShuttingDownResponse(response); } if (this.disallowDelete) { response.statusCode(HttpResponseStatus.METHOD_NOT_ALLOWED.statusCode()); @@ -372,14 +310,7 @@ private Object validateRequestSessionId(HttpClassicServerRequest request, HttpCl return Entity.createText(response, "Session ID required in mcp-session-id header"); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); - 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; + return this.validateSessionExists(sessionId, response); } /** @@ -444,11 +375,13 @@ public void onEmittedData(TextEvent data) { @Override public void onCompleted() { - logger.info("[SSE] Completed SSE emitting. [sessionId={}]", sessionId); + FitMcpStreamableServerTransportProvider.logger.info("[SSE] Completed SSE emitting. [sessionId={}]", + sessionId); try { listeningStream.close(); } catch (Exception e) { - logger.warn("[SSE] Error closing listeningStream on complete. [sessionId={}, error={}]", + FitMcpStreamableServerTransportProvider.logger.warn( + "[SSE] Error closing listeningStream on complete. [sessionId={}, error={}]", sessionId, e.getMessage()); } @@ -456,11 +389,14 @@ public void onCompleted() { @Override public void onFailed(Exception cause) { - logger.warn("[SSE] SSE failed. [sessionId={}, cause={}]", sessionId, cause.getMessage()); + FitMcpStreamableServerTransportProvider.logger.warn("[SSE] SSE failed. [sessionId={}, cause={}]", + sessionId, + cause.getMessage()); try { listeningStream.close(); } catch (Exception e) { - logger.warn("[SSE] Error closing listeningStream on failure. [sessionId={}, error={}]", + FitMcpStreamableServerTransportProvider.logger.warn( + "[SSE] Error closing listeningStream on failure. [sessionId={}, error={}]", sessionId, e.getMessage()); } @@ -600,12 +536,15 @@ public void onEmittedData(TextEvent data) { @Override public void onCompleted() { - logger.info("[SSE] Completed SSE emitting. [sessionId={}]", sessionId); + FitMcpStreamableServerTransportProvider.logger.info("[SSE] Completed SSE emitting. [sessionId={}]", + sessionId); } @Override public void onFailed(Exception e) { - logger.warn("[SSE] SSE failed. [sessionId={}, cause={}]", sessionId, e.getMessage()); + FitMcpStreamableServerTransportProvider.logger.warn("[SSE] SSE failed. [sessionId={}, cause={}]", + sessionId, + e.getMessage()); } }); @@ -632,15 +571,8 @@ public void onFailed(Exception e) { * underlying SSE builder to prevent race conditions when multiple threads attempt to * send messages concurrently. */ - private class FitStreamableMcpSessionTransport implements McpStreamableServerTransport { - private final String sessionId; - private final Emitter emitter; - private final HttpClassicServerResponse response; - - private final ReentrantLock lock = new ReentrantLock(); - - private volatile boolean closed = false; - + private class FitStreamableMcpSessionTransport extends AbstractFitMcpSessionTransport + implements McpStreamableServerTransport { /** * Creates a new session transport with the specified ID and SSE builder. * @@ -650,130 +582,23 @@ private class FitStreamableMcpSessionTransport implements McpStreamableServerTra */ FitStreamableMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { - this.sessionId = sessionId; - this.emitter = emitter; - this.response = response; - logger.info("[SSE] Building SSE emitter. [sessionId={}]", sessionId); + super(sessionId, emitter, response); } - /** - * 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 sendMessage(message, null); + return this.doSendMessage(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.fromRunnable(() -> { - if (this.closed) { - logger.info("[SSE] Attempted to send message to closed session. [sessionId={}]", this.sessionId); - return; - } - - this.lock.lock(); - try { - if (this.closed) { - logger.info("[SSE] Session was closed during message send attempt. [sessionId={}]", - this.sessionId); - return; - } - - // Check if connection is still active before sending - if (!this.response.isActive()) { - logger.warn("[SSE] Connection inactive detected while sending message. [sessionId={}]", - this.sessionId); - this.close(); - return; - } - - String jsonText = FitMcpStreamableServerTransportProvider.this.jsonMapper.writeValueAsString(message); - 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. [sessionId={}, jsonText={}]", - this.sessionId, - jsonText); - } catch (Exception e) { - logger.error("[SSE] Failed to send message to session. [sessionId={}, error={}]", - this.sessionId, - e.getMessage(), - e); - try { - this.emitter.fail(e); - } catch (Exception errorException) { - logger.error("[SSE] Failed to send error to SSE builder. [sessionId={}, error={}]", - this.sessionId, - errorException.getMessage(), - errorException); - } - } finally { - this.lock.unlock(); - } - }); - } - - /** - * 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 - * @param The target type - * @return The converted object of type T - */ - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return FitMcpStreamableServerTransportProvider.this.jsonMapper.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(FitStreamableMcpSessionTransport.this::close); + return this.doSendMessage(message, messageId); } - /** - * Closes the transport immediately. - */ @Override public void close() { - this.lock.lock(); - try { - if (this.closed) { - logger.info("[SSE] Session transport already closed. [sessionId={}]", this.sessionId); - return; - } - - this.closed = true; - - this.emitter.complete(); - logger.info("[SSE] Closed SSE builder successfully. [sessionId={}]", sessionId); - } catch (Exception e) { - logger.warn("[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", - sessionId, - e.getMessage()); - } finally { - this.lock.unlock(); - } + this.doClose(); } - } public static Builder builder() { From 5fa82b07593ea98daf41453aff41da704cb31591 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, 20 Nov 2025 10:58:23 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E9=87=8D=E6=9E=84DefaultMcpServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/FitMcpServerTransportProvider.java | 174 +++++++++--------- .../fel/tool/mcp/server/McpServer.java | 17 -- .../mcp/server/config/McpSseServerConfig.java | 11 +- .../config/McpStreamableServerConfig.java | 11 +- .../mcp/server/support/DefaultMcpServer.java | 60 ++---- .../FitMcpSseServerTransportProvider.java | 59 +++--- ...tMcpStreamableServerTransportProvider.java | 67 ++++--- .../src/main/resources/application.yml | 3 +- .../server/support/DefaultMcpServerTest.java | 36 +--- 9 files changed, 186 insertions(+), 252 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java index 03df8f7e4..c49b402c2 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java @@ -37,6 +37,7 @@ * @since 2025-09-30 */ public abstract class FitMcpServerTransportProvider { + private static final Logger logger = Logger.get(FitMcpServerTransportProvider.class); protected final McpJsonMapper jsonMapper; protected final McpTransportContextExtractor contextExtractor; protected KeepAliveScheduler keepAliveScheduler; @@ -47,14 +48,14 @@ public abstract class FitMcpServerTransportProvider { /** * Constructs a new FitMcpServerTransportProvider instance. * - * @param jsonMapper The JSON mapper for serialization/deserialization - * @param contextExtractor The context extractor for HTTP requests - * @param keepAliveInterval The interval for keep-alive messages, or null to disable + * @param jsonMapper The JSON mapper for serialization/deserialization. + * @param contextExtractor The context extractor for HTTP requests. + * @param keepAliveInterval The interval for keep-alive messages, or null to disable. */ protected FitMcpServerTransportProvider(McpJsonMapper jsonMapper, McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { - Validation.notNull(jsonMapper, "McpJsonMapper must not be null"); - Validation.notNull(contextExtractor, "Context extractor must not be null"); + Validation.notNull(jsonMapper, "MCP Json mapper must not be null."); + Validation.notNull(contextExtractor, "Context extractor must not be null."); this.jsonMapper = jsonMapper; this.contextExtractor = contextExtractor; @@ -63,43 +64,36 @@ protected FitMcpServerTransportProvider(McpJsonMapper jsonMapper, } } - /** - * Gets static logger instance for this transport provider. - * - * @return The logger instance - */ - protected abstract Logger getLogger(); - /** * Initializes the keep-alive scheduler with the specified interval. * - * @param keepAliveInterval The interval for keep-alive messages + * @param keepAliveInterval The interval for keep-alive messages. */ protected abstract void initKeepAliveScheduler(Duration keepAliveInterval); /** * Gets the session ID from a session object. * - * @param session The session object - * @return The session ID + * @param session The session object. + * @return The session ID. */ protected abstract String getSessionId(S session); /** * Closes a session gracefully. * - * @param session The session to close - * @return A Mono that completes when the session is closed + * @param session The session to close. + * @return A Mono that completes when the session is closed. */ protected abstract Mono closeSession(S session); /** * Sends a notification to a specific session. * - * @param session The session to send to - * @param method The notification method name - * @param params The notification parameters - * @return A Mono that completes when the notification is sent + * @param session The session to send to. + * @param method The notification method name. + * @param params The notification parameters. + * @return A Mono that completes when the notification is sent. */ protected abstract Mono sendNotificationToSession(S session, String method, Object params); @@ -108,27 +102,26 @@ protected FitMcpServerTransportProvider(McpJsonMapper jsonMapper, * 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 + * @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. */ public Mono notifyClients(String method, Object params) { if (this.sessions.isEmpty()) { - this.getLogger().debug("No active sessions to broadcast message to"); + logger.debug("No active sessions to broadcast message to."); return Mono.empty(); } - this.getLogger().debug("Attempting to broadcast message. [activeSessions={}]", this.sessions.size()); + logger.debug("Attempting to broadcast message. [activeSessions={}]", this.sessions.size()); return Mono.fromRunnable(() -> this.sessions.values().parallelStream().forEach(session -> { try { this.sendNotificationToSession(session, method, params).block(); } catch (Exception e) { - this.getLogger() - .error("Failed to send message to session. [sessionId={}, error={}]", - this.getSessionId(session), - e.getMessage(), - e); + logger.error("Failed to send message to session. [sessionId={}, error={}]", + this.getSessionId(session), + e.getMessage(), + e); } })); } @@ -136,26 +129,25 @@ 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 + * @return A Mono that completes when all cleanup operations are finished. */ public Mono closeGracefully() { this.isClosing = true; - this.getLogger().debug("Initiating graceful shutdown. [activeSessions={}]", this.sessions.size()); + logger.debug("Initiating graceful shutdown. [activeSessions={}]", this.sessions.size()); return Mono.fromRunnable(() -> { this.sessions.values().parallelStream().forEach(session -> { try { this.closeSession(session).block(); } catch (Exception e) { - this.getLogger() - .error("Failed to close session. [sessionId={}, error={}]", - this.getSessionId(session), - e.getMessage(), - e); + logger.error("Failed to close session. [sessionId={}, error={}]", + this.getSessionId(session), + e.getMessage(), + e); } }); - this.getLogger().debug("Graceful shutdown completed"); + logger.debug("Graceful shutdown completed."); this.sessions.clear(); if (this.keepAliveScheduler != null) { this.keepAliveScheduler.shutdown(); @@ -166,25 +158,25 @@ public Mono closeGracefully() { /** * Creates a response indicating the server is shutting down. * - * @param response The HTTP response - * @return An Entity with the shutdown message + * @param response The HTTP response. + * @return An Entity with the shutdown message. */ protected Object createShuttingDownResponse(HttpClassicServerResponse response) { response.statusCode(HttpResponseStatus.SERVICE_UNAVAILABLE.statusCode()); - return Entity.createText(response, "Server is shutting down"); + return Entity.createText(response, "Server is shutting down."); } /** * Validates that a session exists for the given session ID. * - * @param sessionId The session ID to validate - * @param response The HTTP response to set status code if validation fails - * @return An error Entity if validation fails, null if validation succeeds + * @param sessionId The session ID to validate. + * @param response The HTTP response to set status code if validation fails. + * @return An error Entity if validation fails, null if validation succeeds. */ protected Object validateSessionExists(String sessionId, HttpClassicServerResponse response) { if (sessionId == null || sessionId.isEmpty()) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createText(response, "Session ID missing"); + return Entity.createText(response, "Session ID missing."); } if (this.sessions.get(sessionId) == null) { response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); @@ -211,46 +203,46 @@ protected abstract class AbstractFitMcpSessionTransport { /** * Creates a new session transport. * - * @param sessionId The unique identifier for this session - * @param emitter The emitter for sending SSE events - * @param response The HTTP response for checking connection status + * @param sessionId The unique identifier for this session. + * @param emitter The emitter for sending SSE events. + * @param response The HTTP response for checking connection status. */ protected AbstractFitMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { this.sessionId = sessionId; this.emitter = emitter; this.response = response; - FitMcpServerTransportProvider.this.getLogger() - .info("[SSE] Building SSE emitter. [sessionId={}]", sessionId); + FitMcpServerTransportProvider.logger.info("[SSE] Building SSE emitter. [sessionId={}]", sessionId); } /** * Sends a JSON-RPC message to the client through the SSE connection. * This method is thread-safe and checks if the connection is still active before sending. * - * @param message The JSON-RPC message to send - * @return A Mono that completes when the message has been sent + * @param message The JSON-RPC message to send. + * @return A Mono that completes when the message has been sent. */ protected Mono doSendMessage(McpSchema.JSONRPCMessage message, String messageId) { return Mono.fromRunnable(() -> { if (this.closed) { - FitMcpServerTransportProvider.this.getLogger() - .info("[SSE] Attempted to send message to closed session. [sessionId={}]", this.sessionId); + FitMcpServerTransportProvider.logger.info( + "[SSE] Attempted to send message to closed session. [sessionId={}]", + this.sessionId); return; } this.lock.lock(); try { if (this.closed) { - FitMcpServerTransportProvider.this.getLogger() - .info("[SSE] Session was closed during message send attempt. [sessionId={}]", - this.sessionId); + FitMcpServerTransportProvider.logger.info( + "[SSE] Session was closed during message send attempt. [sessionId={}]", + this.sessionId); return; } if (!this.response.isActive()) { - FitMcpServerTransportProvider.this.getLogger() - .warn("[SSE] Connection inactive detected while sending message. [sessionId={}]", - this.sessionId); + FitMcpServerTransportProvider.logger.warn( + "[SSE] Connection inactive detected while sending message. [sessionId={}]", + this.sessionId); this.doClose(); return; } @@ -263,25 +255,25 @@ protected Mono doSendMessage(McpSchema.JSONRPCMessage message, String mess .build(); this.emitter.emit(textEvent); - FitMcpServerTransportProvider.this.getLogger() - .info("[SSE] Sending message to session. [sessionId={}, eventId={}, jsonText={}]", - this.sessionId, - messageId != null ? messageId : this.sessionId, - jsonText); + FitMcpServerTransportProvider.logger.info( + "[SSE] Sending message to session. [sessionId={}, eventId={}, jsonText={}]", + this.sessionId, + messageId != null ? messageId : this.sessionId, + jsonText); } catch (Exception e) { - FitMcpServerTransportProvider.this.getLogger() - .error("[SSE] Failed to send message to session. [sessionId={}, error={}]", - this.sessionId, - e.getMessage(), - e); + FitMcpServerTransportProvider.logger.error( + "[SSE] Failed to send message to session. [sessionId={}, error={}]", + this.sessionId, + e.getMessage(), + e); try { this.emitter.fail(e); } catch (Exception errorException) { - FitMcpServerTransportProvider.this.getLogger() - .error("[SSE] Failed to send error to SSE builder. [sessionId={}, error={}]", - this.sessionId, - errorException.getMessage(), - errorException); + FitMcpServerTransportProvider.logger.error( + "[SSE] Failed to send error to SSE builder. [sessionId={}, error={}]", + this.sessionId, + errorException.getMessage(), + errorException); } } finally { this.lock.unlock(); @@ -292,10 +284,10 @@ protected Mono doSendMessage(McpSchema.JSONRPCMessage message, String mess /** * Converts data from one type to another using the configured McpJsonMapper. * - * @param data The source data object to convert - * @param typeRef The target type reference - * @param The target type - * @return The converted object of type T + * @param data The source data object to convert. + * @param typeRef The target type reference. + * @param The target type. + * @return The converted object of type T. */ public T unmarshalFrom(Object data, TypeRef typeRef) { return FitMcpServerTransportProvider.this.jsonMapper.convertValue(data, typeRef); @@ -304,7 +296,7 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { /** * Initiates a graceful shutdown of the transport. * - * @return A Mono that completes when the shutdown is complete + * @return A Mono that completes when the shutdown is complete. */ public Mono closeGracefully() { return Mono.fromRunnable(this::doClose); @@ -318,23 +310,23 @@ protected void doClose() { this.lock.lock(); try { if (this.closed) { - FitMcpServerTransportProvider.this.getLogger() - .info("[SSE] Session transport already closed. [sessionId={}]", this.sessionId); + FitMcpServerTransportProvider.logger.info("[SSE] Session transport already closed. [sessionId={}]", + this.sessionId); return; } this.closed = true; - FitMcpServerTransportProvider.this.getLogger() - .debug("[SSE] Closing session transport. [sessionId={}]", this.sessionId); + FitMcpServerTransportProvider.logger.debug("[SSE] Closing session transport. [sessionId={}]", + this.sessionId); this.emitter.complete(); - FitMcpServerTransportProvider.this.getLogger() - .info("[SSE] Closed SSE builder successfully. [sessionId={}]", this.sessionId); + FitMcpServerTransportProvider.logger.info("[SSE] Closed SSE builder successfully. [sessionId={}]", + this.sessionId); } catch (Exception e) { - FitMcpServerTransportProvider.this.getLogger() - .warn("[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", - this.sessionId, - e.getMessage()); + FitMcpServerTransportProvider.logger.warn( + "[SSE] Failed to complete SSE builder. [sessionId={}, error={}]", + this.sessionId, + e.getMessage()); } finally { this.lock.unlock(); } 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 7febd4ddd..4503bf7e9 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 @@ -23,21 +23,4 @@ public interface McpServer { * @return The MCP server tools as a {@link List}{@code <}{@link Tool}{@code >}. */ List getTools(); - - /** - * Registers MCP server tools changed observer. - * - * @param observer The MCP server tools changed observer as a {@link ToolsChangedObserver}. - */ - void registerToolsChangedObserver(ToolsChangedObserver observer); - - /** - * Represents the MCP server tools changed observer. - */ - interface ToolsChangedObserver { - /** - * Called when MCP server tools changed. - */ - void onToolsChanged(); - } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index 689c0d7d6..342c339a5 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -10,9 +10,12 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpSseServerTransportProvider; +import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -27,7 +30,7 @@ public class McpSseServerConfig { @Bean public FitMcpSseServerTransportProvider fitMcpSseServerTransportProvider( - @Value("${mcp.server.keep-alive-interval-seconds}") int keepAliveIntervalSeconds) { + @Value("${mcp.server.ping.interval-seconds}") int keepAliveIntervalSeconds) { return FitMcpSseServerTransportProvider.builder() .jsonMapper(McpJsonMapper.getDefault()) .keepAliveInterval(Duration.ofSeconds(keepAliveIntervalSeconds)) @@ -43,4 +46,10 @@ public McpSyncServer mcpSyncSseServer(FitMcpSseServerTransportProvider transport .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } + + @Bean("DefaultMcpSseServer") + public DefaultMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncServer) { + return new DefaultMcpServer(toolExecuteService, mcpSyncServer); + } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index 1703f258a..f60e55118 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -10,9 +10,12 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; +import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; +import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; +import modelengine.fitframework.annotation.Fit; import modelengine.fitframework.annotation.Value; import java.time.Duration; @@ -27,7 +30,7 @@ public class McpStreamableServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider( - @Value("${mcp.server.keep-alive-interval-seconds}") int keepAliveIntervalSeconds) { + @Value("${mcp.server.ping.interval-seconds}") int keepAliveIntervalSeconds) { return FitMcpStreamableServerTransportProvider.builder() .jsonMapper(McpJsonMapper.getDefault()) .keepAliveInterval(Duration.ofSeconds(keepAliveIntervalSeconds)) @@ -43,4 +46,10 @@ public McpSyncServer mcpSyncStreamableServer(FitMcpStreamableServerTransportProv .requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) .build(); } + + @Bean("DefaultMcpStreamableServer") + public DefaultMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncServer) { + return new DefaultMcpServer(toolExecuteService, mcpSyncServer); + } } 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 index ae9196153..fd786f2ce 100644 --- 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 @@ -18,13 +18,10 @@ 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.annotation.Fit; import modelengine.fitframework.log.Logger; import modelengine.fitframework.util.MapUtils; import modelengine.fitframework.util.StringUtils; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,48 +29,30 @@ /** * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} - * with two MCP Server {@link McpSyncServer} Bean for SSE Server and Streamable Server. + * with MCP Server {@link McpSyncServer} implemented with SDK. * * @author 季聿阶 * @since 2025-05-15 */ -@Component public class DefaultMcpServer implements McpServer, ToolChangedObserver { private static final Logger log = Logger.get(DefaultMcpServer.class); - private final McpSyncServer mcpSyncSseServer; - private final McpSyncServer mcpSyncStreamableServer; - + private final McpSyncServer mcpSyncServer; 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 - * @param mcpSyncSseServer The MCP sync server for SSE transport - * @param mcpSyncStreamableServer The MCP sync server for Streamable transport + * @param toolExecuteService The service used to execute tools when handling tool call requests. + * @param mcpSyncServer The MCP sync server. */ - public DefaultMcpServer(ToolExecuteService toolExecuteService, - @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncSseServer, - @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncStreamableServer) { + public DefaultMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); - this.mcpSyncSseServer = mcpSyncSseServer; - this.mcpSyncStreamableServer = mcpSyncStreamableServer; + this.mcpSyncServer = mcpSyncServer; } @Override public List getTools() { - return this.mcpSyncStreamableServer.listTools() - .stream() - .map(this::convertToFelTool) - .collect(Collectors.toList()); - } - - @Override - public void registerToolsChangedObserver(ToolsChangedObserver observer) { - if (observer != null) { - this.toolsChangedObservers.add(observer); - } + return this.mcpSyncServer.listTools().stream().map(this::convertToFelTool).collect(Collectors.toList()); } @Override @@ -98,15 +77,12 @@ public void onToolAdded(String name, String description, Map par McpServerFeatures.SyncToolSpecification toolSpecification = createToolSpecification(name, description, parameters); try { - this.mcpSyncSseServer.addTool(toolSpecification); - this.mcpSyncStreamableServer.addTool(toolSpecification); + this.mcpSyncServer.addTool(toolSpecification); } catch (Exception e) { - log.error("Failed to added tool to MCP server. [toolName={}]", name); - this.mcpSyncSseServer.removeTool(name); + log.error("Failed to added tool to MCP server. [toolName={}, error={}]", name, e.getMessage()); throw e; } log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, parameters); - this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } @Override @@ -115,19 +91,17 @@ public void onToolRemoved(String name) { log.warn("Tool removal is ignored: tool name is blank."); return; } - this.mcpSyncSseServer.removeTool(name); - this.mcpSyncStreamableServer.removeTool(name); + this.mcpSyncServer.removeTool(name); log.info("Tool removed from MCP server. [toolName={}]", name); - this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged); } /** * Creates a tool specification for the MCP server. * - * @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 configured {@link McpServerFeatures.SyncToolSpecification} + * @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 configured {@link McpServerFeatures.SyncToolSpecification}. */ private McpServerFeatures.SyncToolSpecification createToolSpecification(String name, String description, Map parameters) { @@ -148,9 +122,9 @@ private McpServerFeatures.SyncToolSpecification createToolSpecification(String n /** * Executes a tool and handles any exceptions that may occur. * - * @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 + * @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 { diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 7963ee236..b096740bb 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -62,18 +62,13 @@ public class FitMcpSseServerTransportProvider extends FitMcpServerTransportProvi * @param keepAliveInterval The interval for sending keep-alive messages to clients. * @param contextExtractor The contextExtractor to fill in a * {@link McpTransportContext}. - * @throws IllegalArgumentException if any parameter is null + * @throws IllegalArgumentException if any parameter is null. */ private FitMcpSseServerTransportProvider(McpJsonMapper jsonMapper, Duration keepAliveInterval, McpTransportContextExtractor contextExtractor) { super(jsonMapper, contextExtractor, keepAliveInterval); } - @Override - protected Logger getLogger() { - return logger; - } - @Override protected void initKeepAliveScheduler(Duration keepAliveInterval) { this.keepAliveScheduler = KeepAliveScheduler.builder(() -> this.isClosing @@ -103,7 +98,7 @@ protected Mono sendNotificationToSession(McpServerSession session, String /** * Returns the list of supported MCP protocol versions. * - * @return A list of supported protocol version strings + * @return A list of supported protocol version strings. */ @Override public List protocolVersions() { @@ -113,7 +108,7 @@ public List protocolVersions() { /** * Sets the session factory used to create new MCP server sessions. * - * @param sessionFactory The factory for creating server sessions + * @param sessionFactory The factory for creating server sessions. */ @Override public void setSessionFactory(McpServerSession.Factory sessionFactory) { @@ -130,10 +125,10 @@ public void setSessionFactory(McpServerSession.Factory sessionFactory) { *

  • Maintains the session in the sessions map
  • * * - * @param request The incoming server request - * @param response The HTTP response for SSE communication + * @param request The incoming server request. + * @param response The HTTP response for SSE communication. * @return A {@link Choir}{@code <}{@link TextEvent}{@code >} object for SSE streaming, - * or an error response if the server is shutting down or the connection fails + * or an error response if the server is shutting down or the connection fails. */ @GetMapping(path = SSE_ENDPOINT) public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicServerResponse response) { @@ -179,10 +174,10 @@ public Object handleSseConnection(HttpClassicServerRequest request, HttpClassicS *
  • Returns appropriate HTTP responses based on the processing result
  • * * - * @param request The incoming server request containing the JSON-RPC message - * @param response The HTTP response to set status code and return data - * @param sessionId The session ID from the request parameter - * @return An error {@link Entity} if validation fails, or {@code null} on success + * @param request The incoming server request containing the JSON-RPC message. + * @param response The HTTP response to set status code and return data. + * @param sessionId The session ID from the request parameter. + * @return An error {@link Entity} if validation fails, or {@code null} on success. */ @PostMapping(path = MESSAGE_ENDPOINT) public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerResponse response, @@ -210,7 +205,7 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR logger.error("[POST] Failed to deserialize message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format").build()); + McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format.").build()); } catch (Exception e) { logger.error("[POST] Error handling message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); @@ -224,8 +219,8 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR * The observer removes the session from the sessions map when the connection * completes or fails. * - * @param emitter The SSE emitter to observe - * @param sessionId The session ID associated with this emitter + * @param emitter The SSE emitter to observe. + * @param sessionId The session ID associated with this emitter. */ private void addEmitterObserver(Emitter emitter, String sessionId) { emitter.observe(new Emitter.Observer() { @@ -259,14 +254,14 @@ public void onFailed(Exception cause) { * the existence of the corresponding session in the active sessions map. * * @param sessionId The {@link String} session ID in request parameter. - * @param response The {@link HttpClassicServerResponse} to set status code if validation fails + * @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(String sessionId, HttpClassicServerResponse response) { if (sessionId.isEmpty()) { response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); - return Entity.createText(response, "Session ID missing in message endpoint"); + return Entity.createText(response, "Session ID missing in message endpoint."); } return this.validateSessionExists(sessionId, response); } @@ -316,11 +311,11 @@ public static class Builder { /** * Sets the JSON object mapper to use for message serialization/deserialization. * - * @param jsonMapper The object mapper to use - * @return This builder instance for method chaining + * @param jsonMapper The object mapper to use. + * @return This builder instance for method chaining. */ public Builder jsonMapper(McpJsonMapper jsonMapper) { - Validation.notNull(jsonMapper, "McpJsonMapper must not be null"); + Validation.notNull(jsonMapper, "MCP Json mapper must not be null."); this.jsonMapper = jsonMapper; return this; } @@ -330,8 +325,8 @@ public Builder jsonMapper(McpJsonMapper jsonMapper) { *

    * If not specified, keep-alive pings will be disabled. * - * @param keepAliveInterval The interval duration for keep-alive pings - * @return This builder instance for method chaining + * @param keepAliveInterval The interval duration for keep-alive pings. + * @return This builder instance for method chaining. */ public Builder keepAliveInterval(Duration keepAliveInterval) { this.keepAliveInterval = keepAliveInterval; @@ -346,11 +341,11 @@ public Builder keepAliveInterval(Duration keepAliveInterval) { * * @param contextExtractor The contextExtractor to fill in a * {@link McpTransportContext}. - * @return this builder instance - * @throws IllegalArgumentException if contextExtractor is null + * @return This builder instance. + * @throws IllegalArgumentException if contextExtractor is null. */ public Builder contextExtractor(McpTransportContextExtractor contextExtractor) { - Validation.notNull(contextExtractor, "contextExtractor must not be null"); + Validation.notNull(contextExtractor, "Context extractor must not be null."); this.contextExtractor = contextExtractor; return this; } @@ -359,11 +354,11 @@ public Builder contextExtractor(McpTransportContextExtractor contextExtractor, Duration keepAliveInterval) { @@ -74,11 +74,6 @@ private FitMcpStreamableServerTransportProvider(McpJsonMapper jsonMapper, boolea this.disallowDelete = disallowDelete; } - @Override - protected Logger getLogger() { - return logger; - } - @Override protected void initKeepAliveScheduler(Duration keepAliveInterval) { this.keepAliveScheduler = KeepAliveScheduler.builder(() -> this.isClosing @@ -120,9 +115,10 @@ public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) /** * 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 + * @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) { @@ -175,9 +171,10 @@ public Object handleGet(HttpClassicServerRequest request, HttpClassicServerRespo /** * 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 - * @return Return the HTTP response body {@link Entity} or a {@link Choir}{@code <}{@link TextEvent}{@code >} object + * @param request The incoming server request containing the JSON-RPC message. + * @param response The HTTP response. + * @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) { @@ -206,7 +203,7 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp logger.error("[POST] Failed to deserialize message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format").build()); + McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format.").build()); } catch (Exception e) { logger.error("[POST] Error handling message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); @@ -218,8 +215,8 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp /** * Handles DELETE requests for session deletion. * - * @param request The incoming server request - * @param response The HTTP response + * @param request The incoming server request. + * @param response The HTTP response. * @return Return HTTP response body {@link Entity}. */ @DeleteMapping(path = MESSAGE_ENDPOINT) @@ -267,7 +264,7 @@ private Object validateGetAcceptHeaders(HttpClassicServerRequest request, HttpCl 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 Entity.createText(response, "Invalid Accept header. Expected TEXT_EVENT_STREAM."); } return null; } @@ -288,7 +285,7 @@ private Object validatePostAcceptHeaders(HttpClassicServerRequest request, HttpC 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") + .message("Invalid Accept headers. Expected TEXT_EVENT_STREAM and APPLICATION_JSON.") .build()); } return null; @@ -307,7 +304,7 @@ private Object validatePostAcceptHeaders(HttpClassicServerRequest request, HttpC 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"); + return Entity.createText(response, "Session ID required in mcp-session-id header."); } String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); return this.validateSessionExists(sessionId, response); @@ -474,7 +471,7 @@ private Object handleJsonRpcMessage(McpSchema.JSONRPCMessage message, HttpClassi } else { response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); return Entity.createObject(response, - McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type").build()); + McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message("Unknown message type.").build()); } } @@ -576,9 +573,9 @@ private class FitStreamableMcpSessionTransport extends AbstractFitMcpSessionTran /** * 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 + * @param sessionId The unique identifier for this session. + * @param emitter The emitter for sending events. + * @param response The HTTP response for checking connection status. */ FitStreamableMcpSessionTransport(String sessionId, Emitter emitter, HttpClassicServerResponse response) { @@ -619,11 +616,11 @@ public static class Builder { * Sets the jsonMapper to use for JSON serialization/deserialization of MCP messages. * * @param jsonMapper The jsonMapper instance. Must not be null. - * @return this builder instance - * @throws IllegalArgumentException if jsonMapper is null + * @return This builder instance. + * @throws IllegalArgumentException if jsonMapper is null. */ public Builder jsonMapper(McpJsonMapper jsonMapper) { - Validation.notNull(jsonMapper, "jsonMapper must not be null"); + Validation.notNull(jsonMapper, "Json mapper must not be null."); this.jsonMapper = jsonMapper; return this; } @@ -631,8 +628,8 @@ public Builder jsonMapper(McpJsonMapper jsonMapper) { /** * Sets whether to disallow DELETE requests on the endpoint. * - * @param disallowDelete true to disallow DELETE requests, false otherwise - * @return this builder instance + * @param disallowDelete true to disallow DELETE requests, false otherwise. + * @return This builder instance. */ public Builder disallowDelete(boolean disallowDelete) { this.disallowDelete = disallowDelete; @@ -647,11 +644,11 @@ public Builder disallowDelete(boolean disallowDelete) { * * @param contextExtractor The contextExtractor to fill in a * {@link McpTransportContext}. - * @return this builder instance - * @throws IllegalArgumentException if contextExtractor is null + * @return This builder instance. + * @throws IllegalArgumentException if contextExtractor is null. */ public Builder contextExtractor(McpTransportContextExtractor contextExtractor) { - Validation.notNull(contextExtractor, "contextExtractor must not be null"); + Validation.notNull(contextExtractor, "Context extractor must not be null."); this.contextExtractor = contextExtractor; return this; } @@ -661,8 +658,8 @@ public Builder contextExtractor(McpTransportContextExtractor new DefaultMcpServer(null, mcpSyncSseServer, mcpStreamableSyncServer)); + () -> new DefaultMcpServer(null, mcpStreamableSyncServer)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } - @Nested - @DisplayName("registerToolsChangedObserver and Notification Tests") - class GivenRegisterAndNotify { - @Test - @DisplayName("Should notify observers when tools are added or removed") - void notifyObserversOnToolAddOrRemove() { - DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); - McpServer.ToolsChangedObserver observer = mock(McpServer.ToolsChangedObserver.class); - server.registerToolsChangedObserver(observer); - - 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"); - verify(observer, times(2)).onToolsChanged(); - } - } - @Nested @DisplayName("onToolAdded Method Tests") class GivenOnToolAdded { @@ -95,7 +69,7 @@ class GivenOnToolAdded { @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); + new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get() @@ -119,7 +93,7 @@ void addToolSuccessfully() { @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); + new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -144,7 +118,7 @@ class GivenOnToolRemoved { @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); + new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -161,7 +135,7 @@ void removeToolSuccessfully() { @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpSyncSseServer, mcpStreamableSyncServer); + new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) From f81a0b56d4bbb5289ce090ea2eeb9f85b9ed7486 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, 20 Nov 2025 11:30:32 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=8A=9B=E5=87=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/FitMcpServerTransportProvider.java | 18 ++++ .../FitMcpSseServerTransportProvider.java | 22 ++-- ...tMcpStreamableServerTransportProvider.java | 101 +++++++++--------- 3 files changed, 76 insertions(+), 65 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java index c49b402c2..5c5551336 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java @@ -23,6 +23,7 @@ import modelengine.fitframework.log.Logger; import reactor.core.publisher.Mono; +import java.io.IOException; import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -188,6 +189,23 @@ protected Object validateSessionExists(String sessionId, HttpClassicServerRespon return null; } + /** + * Deserializes a JSON-RPC message from the request body. + * + * @param requestBody The request body string to deserialize. + * @param response The HTTP response to set error status if deserialization fails. + * @return The deserialized {@link McpSchema.JSONRPCMessage}, or {@code null} if deserialization fails. + */ + protected McpSchema.JSONRPCMessage deserializeMessage(String requestBody, HttpClassicServerResponse response) { + try { + return McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); + } catch (IllegalArgumentException | IOException e) { + logger.error("[POST] Failed to deserialize message. [error={}]", e.getMessage(), e); + response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); + return null; + } + } + /** * Abstract base class for session transport implementations. * Provides common functionality for sending messages over SSE connections. diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index b096740bb..4ecb01ee8 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -32,7 +32,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; @@ -190,22 +189,19 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR return sessionError; } McpServerSession session = this.sessions.get(sessionId); - try { - McpTransportContext transportContext = this.contextExtractor.extract(request); - String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); - logger.info("[POST] Receiving message from session. [sessionId={}, requestBody={}]", - sessionId, - requestBody); + String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); + McpSchema.JSONRPCMessage message = this.deserializeMessage(requestBody, response); + if (message == null) { + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format.").build()); + } + logger.info("[POST] Receiving message from session. [sessionId={}, requestBody={}]", sessionId, requestBody); + McpTransportContext transportContext = this.contextExtractor.extract(request); + try { session.handle(message).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); response.statusCode(HttpResponseStatus.OK.statusCode()); return null; - } catch (IllegalArgumentException | IOException e) { - logger.error("[POST] Failed to deserialize message. [error={}]", 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("[POST] Error handling message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); 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 eb324b92f..2cba0c9d6 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 @@ -36,7 +36,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; @@ -186,11 +185,14 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp return headerError; } + String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); + McpSchema.JSONRPCMessage message = this.deserializeMessage(requestBody, response); + if (message == null) { + return Entity.createObject(response, + McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format.").build()); + } McpTransportContext transportContext = this.contextExtractor.extract(request); try { - String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); - McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); - // Handle JSONRPCMessage if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest && jsonrpcRequest.method() .equals(McpSchema.METHOD_INITIALIZE)) { @@ -199,11 +201,6 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp } else { return this.handleJsonRpcMessage(message, request, requestBody, transportContext, response); } - } catch (IllegalArgumentException | IOException e) { - logger.error("[POST] Failed to deserialize message. [error={}]", 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("[POST] Error handling message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.statusCode()); @@ -256,9 +253,9 @@ public Object handleDelete(HttpClassicServerRequest request, HttpClassicServerRe * 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 + * @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(""); @@ -274,9 +271,9 @@ private Object validateGetAcceptHeaders(HttpClassicServerRequest request, HttpCl * 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 + * @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(""); @@ -296,10 +293,10 @@ private Object validatePostAcceptHeaders(HttpClassicServerRequest request, HttpC * 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 + * @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)) { @@ -315,12 +312,12 @@ private Object validateRequestSessionId(HttpClassicServerRequest request, HttpCl * 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 + * @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, @@ -353,10 +350,10 @@ private void handleReplaySseRequest(HttpClassicServerRequest request, McpTranspo * 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 + * @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) { @@ -406,13 +403,13 @@ public void onFailed(Exception cause) { * 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 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 + * 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 + * on success, or an error {@link Entity} with {@link McpError} on failure. */ private Object handleInitializeRequest(HttpClassicServerRequest request, HttpClassicServerResponse response, McpSchema.JSONRPCRequest jsonrpcRequest) { @@ -442,12 +439,12 @@ 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 + * @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) { @@ -480,10 +477,10 @@ private Object handleJsonRpcMessage(McpSchema.JSONRPCMessage message, HttpClassi * 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 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 + * @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. */ private void handleJsonRpcResponse(McpSchema.JSONRPCResponse jsonrpcResponse, McpStreamableServerSession session, McpTransportContext transportContext, HttpClassicServerResponse response) { @@ -496,10 +493,10 @@ private void handleJsonRpcResponse(McpSchema.JSONRPCResponse jsonrpcResponse, Mc * 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 + * @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. */ private void handleJsonRpcNotification(McpSchema.JSONRPCNotification jsonrpcNotification, McpStreamableServerSession session, McpTransportContext transportContext, @@ -515,12 +512,12 @@ private void handleJsonRpcNotification(McpSchema.JSONRPCNotification jsonrpcNoti * 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 + * @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) { From e03b7c10538d48deeed11b54c755db3efc21b93f 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, 20 Nov 2025 16:52:41 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E6=B3=A8=E5=86=8C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/plugins/tool-mcp-server/README.md | 2 +- ...efaultMcpServer.java => FitMcpServer.java} | 25 ++++++++++----- .../server/FitMcpServerTransportProvider.java | 4 +-- .../fel/tool/mcp/server/McpServer.java | 26 --------------- .../mcp/server/config/McpSseServerConfig.java | 10 +++--- .../config/McpStreamableServerConfig.java | 10 +++--- .../FitMcpSseServerTransportProvider.java | 3 +- ...tMcpStreamableServerTransportProvider.java | 1 + .../server/support/DefaultMcpServerTest.java | 31 ++++++++---------- .../tool/support/SimpleToolRepository.java | 32 +++++++++++-------- .../service/ToolChangedObserverRegistry.java | 30 +++++++++++++++++ 11 files changed, 96 insertions(+), 78 deletions(-) rename framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/{support/DefaultMcpServer.java => FitMcpServer.java} (89%) delete mode 100644 framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java create mode 100644 framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java diff --git a/framework/fel/java/plugins/tool-mcp-server/README.md b/framework/fel/java/plugins/tool-mcp-server/README.md index f9c924870..f9d5694e4 100644 --- a/framework/fel/java/plugins/tool-mcp-server/README.md +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -28,7 +28,7 @@ public McpSyncServer mcpSyncSseServer(...) { ... } @Bean("McpSyncStreamableServer") public McpSyncServer mcpSyncStreamableServer(...) { ... } -// DefaultMcpServer.java +// FitMcpServer.java public DefaultMcpServer(ToolExecuteService toolExecuteService, @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncSseServer, @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncStreamableServer) { 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/FitMcpServer.java similarity index 89% rename from framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java rename to framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServer.java index fd786f2ce..6ca05cba4 100644 --- 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/FitMcpServer.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 static modelengine.fel.tool.info.schema.PluginSchema.TYPE; import static modelengine.fel.tool.info.schema.ToolsSchema.PROPERTIES; @@ -15,9 +15,10 @@ import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; 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.ToolChangedObserverRegistry; import modelengine.fel.tool.service.ToolExecuteService; +import modelengine.fitframework.ioc.annotation.PreDestroy; import modelengine.fitframework.log.Logger; import modelengine.fitframework.util.MapUtils; import modelengine.fitframework.util.StringUtils; @@ -28,29 +29,37 @@ import java.util.stream.Collectors; /** - * Mcp Server implementing interface {@link McpServer}, {@link ToolChangedObserver} + * Mcp Server implementing interface {@link ToolChangedObserver} * with MCP Server {@link McpSyncServer} implemented with SDK. * * @author 季聿阶 * @since 2025-05-15 */ -public class DefaultMcpServer implements McpServer, ToolChangedObserver { - private static final Logger log = Logger.get(DefaultMcpServer.class); +public class FitMcpServer implements ToolChangedObserver { + private static final Logger log = Logger.get(FitMcpServer.class); private final McpSyncServer mcpSyncServer; private final ToolExecuteService toolExecuteService; + private final ToolChangedObserverRegistry toolChangedObserverRegistry; /** - * Constructs a new instance of the DefaultMcpServer class. + * Constructs a new instance of the FitMcpServer class. * * @param toolExecuteService The service used to execute tools when handling tool call requests. * @param mcpSyncServer The MCP sync server. */ - public DefaultMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer) { + public FitMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSyncServer, + ToolChangedObserverRegistry toolChangedObserverRegistry) { this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); this.mcpSyncServer = mcpSyncServer; + this.toolChangedObserverRegistry = toolChangedObserverRegistry; + this.toolChangedObserverRegistry.registerToolChangedObserver(this); + } + + @PreDestroy + public void onDestroy() { + this.toolChangedObserverRegistry.unregisterToolChangedObserver(this); } - @Override public List getTools() { return this.mcpSyncServer.listTools().stream().map(this::convertToFelTool).collect(Collectors.toList()); } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java index 5c5551336..c2f4c1ce3 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServerTransportProvider.java @@ -35,7 +35,7 @@ * * @param The session type * @author 黄可欣 - * @since 2025-09-30 + * @since 2025-11-19 */ public abstract class FitMcpServerTransportProvider { private static final Logger logger = Logger.get(FitMcpServerTransportProvider.class); @@ -200,7 +200,7 @@ protected McpSchema.JSONRPCMessage deserializeMessage(String requestBody, HttpCl try { return McpSchema.deserializeJsonRpcMessage(this.jsonMapper, requestBody); } catch (IllegalArgumentException | IOException e) { - logger.error("[POST] Failed to deserialize message. [error={}]", e.getMessage(), e); + logger.error("Failed to deserialize message. [error={}]", e.getMessage(), e); response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); return null; } 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 deleted file mode 100644 index 4503bf7e9..000000000 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java +++ /dev/null @@ -1,26 +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 modelengine.fel.tool.mcp.entity.Tool; - -import java.util.List; - -/** - * Represents the MCP Server. - * - * @author 季聿阶 - * @since 2025-05-15 - */ -public interface McpServer { - /** - * Gets MCP server tools. - * - * @return The MCP server tools as a {@link List}{@code <}{@link Tool}{@code >}. - */ - List getTools(); -} diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index 342c339a5..7a6ed3f71 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -10,8 +10,9 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; +import modelengine.fel.tool.mcp.server.FitMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpSseServerTransportProvider; +import modelengine.fel.tool.service.ToolChangedObserverRegistry; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; @@ -48,8 +49,9 @@ public McpSyncServer mcpSyncSseServer(FitMcpSseServerTransportProvider transport } @Bean("DefaultMcpSseServer") - public DefaultMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, - @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncServer) { - return new DefaultMcpServer(toolExecuteService, mcpSyncServer); + public FitMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncServer, + ToolChangedObserverRegistry toolChangedObserverRegistry) { + return new FitMcpServer(toolExecuteService, mcpSyncServer, toolChangedObserverRegistry); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index f60e55118..9f0d94dcf 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -10,8 +10,9 @@ import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; -import modelengine.fel.tool.mcp.server.support.DefaultMcpServer; +import modelengine.fel.tool.mcp.server.FitMcpServer; import modelengine.fel.tool.mcp.server.transport.FitMcpStreamableServerTransportProvider; +import modelengine.fel.tool.service.ToolChangedObserverRegistry; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.annotation.Bean; import modelengine.fitframework.annotation.Component; @@ -48,8 +49,9 @@ public McpSyncServer mcpSyncStreamableServer(FitMcpStreamableServerTransportProv } @Bean("DefaultMcpStreamableServer") - public DefaultMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, - @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncServer) { - return new DefaultMcpServer(toolExecuteService, mcpSyncServer); + public FitMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, + @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncServer, + ToolChangedObserverRegistry toolChangedObserverRegistry) { + return new FitMcpServer(toolExecuteService, mcpSyncServer, toolChangedObserverRegistry); } } diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java index 4ecb01ee8..86abcd76e 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/transport/FitMcpSseServerTransportProvider.java @@ -43,7 +43,7 @@ * SDK. * * @author 黄可欣 - * @since 2025-09-30 + * @since 2025-11-19 */ public class FitMcpSseServerTransportProvider extends FitMcpServerTransportProvider implements McpServerTransportProvider { @@ -193,6 +193,7 @@ public Object handleMessage(HttpClassicServerRequest request, HttpClassicServerR String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); McpSchema.JSONRPCMessage message = this.deserializeMessage(requestBody, response); if (message == null) { + logger.error("[POST] Invalid message format. [sessionId={}, requestBody={}]", sessionId, requestBody); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format.").build()); } 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 2cba0c9d6..0cd19c363 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 @@ -188,6 +188,7 @@ public Object handlePost(HttpClassicServerRequest request, HttpClassicServerResp String requestBody = new String(request.entityBytes(), StandardCharsets.UTF_8); McpSchema.JSONRPCMessage message = this.deserializeMessage(requestBody, response); if (message == null) { + logger.error("[POST] Invalid message format. [requestBody={}]", requestBody); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.PARSE_ERROR).message("Invalid message format.").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 e90ec06eb..b34c483c1 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 @@ -9,14 +9,12 @@ 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.config.McpSseServerConfig; +import modelengine.fel.tool.mcp.server.FitMcpServer; import modelengine.fel.tool.mcp.server.config.McpStreamableServerConfig; +import modelengine.fel.tool.service.ToolChangedObserverRegistry; import modelengine.fel.tool.service.ToolExecuteService; import modelengine.fitframework.util.MapBuilder; @@ -30,22 +28,23 @@ import java.util.Map; /** - * Unit test for {@link DefaultMcpServer}. + * Unit test for {@link FitMcpServer}. * * @author 季聿阶 * @since 2025-05-20 */ -@DisplayName("Unit tests for DefaultMcpServer") +@DisplayName("Unit tests for FitMcpServer") public class DefaultMcpServerTest { private ToolExecuteService toolExecuteService; - private McpSyncServer mcpSyncSseServer; - private McpSyncServer mcpStreamableSyncServer; + private ToolChangedObserverRegistry toolChangedObserverRegistry; + private McpSyncServer mcpSyncServer; @BeforeEach void setup() { this.toolExecuteService = mock(ToolExecuteService.class); + this.toolChangedObserverRegistry = mock(ToolChangedObserverRegistry.class); McpStreamableServerConfig streamableConfig = new McpStreamableServerConfig(); - this.mcpStreamableSyncServer = + this.mcpSyncServer = streamableConfig.mcpSyncStreamableServer(streamableConfig.fitMcpStreamableServerTransportProvider(30), 10); } @@ -57,7 +56,7 @@ class GivenConstructor { @DisplayName("Should throw IllegalArgumentException when toolExecuteService is null") void throwIllegalArgumentExceptionWhenToolExecuteServiceIsNull() { IllegalArgumentException exception = catchThrowableOfType(IllegalArgumentException.class, - () -> new DefaultMcpServer(null, mcpStreamableSyncServer)); + () -> new FitMcpServer(null, mcpSyncServer, toolChangedObserverRegistry)); assertThat(exception).isNotNull().hasMessage("The tool execute service cannot be null."); } } @@ -68,8 +67,7 @@ class GivenOnToolAdded { @Test @DisplayName("Should add tool successfully with valid parameters") void addToolSuccessfully() { - DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); + FitMcpServer server = new FitMcpServer(toolExecuteService, mcpSyncServer, toolChangedObserverRegistry); String name = "tool1"; String description = "description1"; Map schema = MapBuilder.get() @@ -92,8 +90,7 @@ void addToolSuccessfully() { @Test @DisplayName("Should ignore invalid parameters and not add any tool") void ignoreInvalidParameters() { - DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); + FitMcpServer server = new FitMcpServer(toolExecuteService, mcpSyncServer, toolChangedObserverRegistry); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -117,8 +114,7 @@ class GivenOnToolRemoved { @Test @DisplayName("Should remove an added tool correctly") void removeToolSuccessfully() { - DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); + FitMcpServer server = new FitMcpServer(toolExecuteService, mcpSyncServer, toolChangedObserverRegistry); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) @@ -134,8 +130,7 @@ void removeToolSuccessfully() { @Test @DisplayName("Should ignore removal if name is blank") void ignoreBlankName() { - DefaultMcpServer server = - new DefaultMcpServer(toolExecuteService, mcpStreamableSyncServer); + FitMcpServer server = new FitMcpServer(toolExecuteService, mcpSyncServer, toolChangedObserverRegistry); Map schema = MapBuilder.get() .put("type", "object") .put("properties", Collections.emptyMap()) diff --git a/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java b/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java index ae9bd7739..6ae4f9a42 100644 --- a/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java +++ b/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java @@ -7,17 +7,18 @@ package modelengine.fel.tool.support; import static modelengine.fitframework.inspection.Validation.notBlank; -import static modelengine.fitframework.inspection.Validation.notNull; import static modelengine.fitframework.util.ObjectUtils.cast; import modelengine.fel.core.tool.ToolInfo; import modelengine.fel.tool.ToolInfoEntity; import modelengine.fel.tool.service.ToolChangedObserver; +import modelengine.fel.tool.service.ToolChangedObserverRegistry; import modelengine.fel.tool.service.ToolRepository; import modelengine.fitframework.annotation.Component; import modelengine.fitframework.log.Logger; import modelengine.fitframework.util.StringUtils; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -31,21 +32,24 @@ * @since 2024-08-15 */ @Component -public class SimpleToolRepository implements ToolRepository { +public class SimpleToolRepository implements ToolRepository, ToolChangedObserverRegistry { private static final Logger log = Logger.get(SimpleToolRepository.class); - private final ToolChangedObserver toolChangedObserver; private final Map toolCache = new ConcurrentHashMap<>(); + private final List toolChangedObservers = new ArrayList<>(); - /** - * Constructs a new instance of the SimpleToolRepository class. - * - * @param toolChangedObserver The observer to be notified when tools are added or removed, as a - * {@link ToolChangedObserver}. - * @throws IllegalStateException If {@code toolChangedObserver} is null. - */ - public SimpleToolRepository(ToolChangedObserver toolChangedObserver) { - this.toolChangedObserver = notNull(toolChangedObserver, "The tool changed observer cannot be null."); + @Override + public void registerToolChangedObserver(ToolChangedObserver observer) { + if (observer != null) { + this.toolChangedObservers.add(observer); + } + } + + @Override + public void unregisterToolChangedObserver(ToolChangedObserver observer) { + if (observer != null) { + this.toolChangedObservers.remove(observer); + } } @Override @@ -57,7 +61,7 @@ public void addTool(ToolInfoEntity tool) { this.toolCache.put(uniqueName, tool); log.info("Register tool[uniqueName={}] success.", uniqueName); Map parameters = cast(tool.schema().get("parameters")); - this.toolChangedObserver.onToolAdded(uniqueName, tool.description(), parameters); + this.toolChangedObservers.forEach(observer -> observer.onToolAdded(uniqueName, tool.description(), parameters)); } @Override @@ -68,7 +72,7 @@ public void deleteTool(String namespace, String toolName) { String uniqueName = ToolInfo.identify(namespace, toolName); this.toolCache.remove(uniqueName); log.info("Unregister tool[uniqueName={}] success.", uniqueName); - this.toolChangedObserver.onToolRemoved(uniqueName); + this.toolChangedObservers.forEach(observer -> observer.onToolRemoved(uniqueName)); } @Override diff --git a/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java b/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java new file mode 100644 index 000000000..2a68e3cf9 --- /dev/null +++ b/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * 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.service; + +/** + * 工具变更观察者注册表接口。 + * + * @author 黄可欣 + * @since 2025-11-20 + */ +public interface ToolChangedObserverRegistry { + + /** + * 注册工具变更观察者。 + * + * @param observer 待注册的工具变更观察者。 + */ + void registerToolChangedObserver(ToolChangedObserver observer); + + /** + * 注销工具变更观察者。 + * + * @param observer 需要注销的工具变更观察者。 + */ + void unregisterToolChangedObserver(ToolChangedObserver observer); +} From 89c04722ef640652295fd683ba470f06c7cd5b7b 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, 21 Nov 2025 10:26:10 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=E5=B7=A5=E5=85=B7=E5=A2=9E=E5=88=A0=E6=8D=95=E8=8E=B7?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fel/tool/mcp/server/FitMcpServer.java | 4 +-- .../mcp/server/config/McpSseServerConfig.java | 2 +- .../config/McpStreamableServerConfig.java | 6 ++-- .../src/main/resources/application.yml | 4 ++- .../server/support/DefaultMcpServerTest.java | 4 +-- .../tool/support/SimpleToolRepository.java | 28 ++++++++++++++++--- .../service/ToolChangedObserverRegistry.java | 5 ++-- 7 files changed, 38 insertions(+), 15 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServer.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServer.java index 6ca05cba4..12e4b915e 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServer.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/FitMcpServer.java @@ -52,12 +52,12 @@ public FitMcpServer(ToolExecuteService toolExecuteService, McpSyncServer mcpSync this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null."); this.mcpSyncServer = mcpSyncServer; this.toolChangedObserverRegistry = toolChangedObserverRegistry; - this.toolChangedObserverRegistry.registerToolChangedObserver(this); + this.toolChangedObserverRegistry.register(this); } @PreDestroy public void onDestroy() { - this.toolChangedObserverRegistry.unregisterToolChangedObserver(this); + this.toolChangedObserverRegistry.unregister(this); } public List getTools() { diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java index 7a6ed3f71..91baf3a94 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpSseServerConfig.java @@ -48,7 +48,7 @@ public McpSyncServer mcpSyncSseServer(FitMcpSseServerTransportProvider transport .build(); } - @Bean("DefaultMcpSseServer") + @Bean("McpSseServer") public FitMcpServer defaultMcpSseServer(ToolExecuteService toolExecuteService, @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncServer, ToolChangedObserverRegistry toolChangedObserverRegistry) { diff --git a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java index 9f0d94dcf..8c4e8ed2b 100644 --- a/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java +++ b/framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/config/McpStreamableServerConfig.java @@ -31,10 +31,12 @@ public class McpStreamableServerConfig { @Bean public FitMcpStreamableServerTransportProvider fitMcpStreamableServerTransportProvider( - @Value("${mcp.server.ping.interval-seconds}") int keepAliveIntervalSeconds) { + @Value("${mcp.server.ping.interval-seconds}") int keepAliveIntervalSeconds, + @Value("${mcp.server.streamable.disallow-delete}") boolean disallowDelete) { return FitMcpStreamableServerTransportProvider.builder() .jsonMapper(McpJsonMapper.getDefault()) .keepAliveInterval(Duration.ofSeconds(keepAliveIntervalSeconds)) + .disallowDelete(disallowDelete) .build(); } @@ -48,7 +50,7 @@ public McpSyncServer mcpSyncStreamableServer(FitMcpStreamableServerTransportProv .build(); } - @Bean("DefaultMcpStreamableServer") + @Bean("McpStreamableServer") public FitMcpServer defaultMcpStreamableServer(ToolExecuteService toolExecuteService, @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncServer, ToolChangedObserverRegistry toolChangedObserverRegistry) { 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 debc46f5a..64922166e 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 @@ -8,4 +8,6 @@ mcp: request: timeout-seconds: 60 ping: - interval-seconds: 30 \ No newline at end of file + interval-seconds: 30 + streamable: + disallow-delete: False \ 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/DefaultMcpServerTest.java index b34c483c1..b947d78da 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 @@ -45,8 +45,8 @@ void setup() { this.toolChangedObserverRegistry = mock(ToolChangedObserverRegistry.class); McpStreamableServerConfig streamableConfig = new McpStreamableServerConfig(); this.mcpSyncServer = - streamableConfig.mcpSyncStreamableServer(streamableConfig.fitMcpStreamableServerTransportProvider(30), - 10); + streamableConfig.mcpSyncStreamableServer(streamableConfig.fitMcpStreamableServerTransportProvider(30, + false), 10); } @Nested diff --git a/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java b/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java index 6ae4f9a42..0385805ff 100644 --- a/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java +++ b/framework/fel/java/plugins/tool-repository-simple/src/main/java/modelengine/fel/tool/support/SimpleToolRepository.java @@ -39,14 +39,14 @@ public class SimpleToolRepository implements ToolRepository, ToolChangedObserver private final List toolChangedObservers = new ArrayList<>(); @Override - public void registerToolChangedObserver(ToolChangedObserver observer) { + public void register(ToolChangedObserver observer) { if (observer != null) { this.toolChangedObservers.add(observer); } } @Override - public void unregisterToolChangedObserver(ToolChangedObserver observer) { + public void unregister(ToolChangedObserver observer) { if (observer != null) { this.toolChangedObservers.remove(observer); } @@ -61,7 +61,17 @@ public void addTool(ToolInfoEntity tool) { this.toolCache.put(uniqueName, tool); log.info("Register tool[uniqueName={}] success.", uniqueName); Map parameters = cast(tool.schema().get("parameters")); - this.toolChangedObservers.forEach(observer -> observer.onToolAdded(uniqueName, tool.description(), parameters)); + this.toolChangedObservers.forEach(observer -> { + try { + observer.onToolAdded(uniqueName, tool.description(), parameters); + } catch (Exception e) { + log.error("Failed to notify observer of tool added. [observer={}, uniqueName={}, error={}]", + observer.getClass().getName(), + uniqueName, + e.getMessage(), + e); + } + }); } @Override @@ -72,7 +82,17 @@ public void deleteTool(String namespace, String toolName) { String uniqueName = ToolInfo.identify(namespace, toolName); this.toolCache.remove(uniqueName); log.info("Unregister tool[uniqueName={}] success.", uniqueName); - this.toolChangedObservers.forEach(observer -> observer.onToolRemoved(uniqueName)); + this.toolChangedObservers.forEach(observer -> { + try { + observer.onToolRemoved(uniqueName); + } catch (Exception e) { + log.error("Failed to notify observer of tool removed. [observer={}, uniqueName={}, error={}]", + observer.getClass().getName(), + uniqueName, + e.getMessage(), + e); + } + }); } @Override diff --git a/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java b/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java index 2a68e3cf9..0e1211d26 100644 --- a/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java +++ b/framework/fel/java/services/tool-service/src/main/java/modelengine/fel/tool/service/ToolChangedObserverRegistry.java @@ -13,18 +13,17 @@ * @since 2025-11-20 */ public interface ToolChangedObserverRegistry { - /** * 注册工具变更观察者。 * * @param observer 待注册的工具变更观察者。 */ - void registerToolChangedObserver(ToolChangedObserver observer); + void register(ToolChangedObserver observer); /** * 注销工具变更观察者。 * * @param observer 需要注销的工具变更观察者。 */ - void unregisterToolChangedObserver(ToolChangedObserver observer); + void unregister(ToolChangedObserver observer); } From 685a6b9b6c017f44c33efde3c8117a759c3c0605 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, 21 Nov 2025 11:01:19 +0800 Subject: [PATCH 17/18] =?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 | 533 ++++++++++-------- 1 file changed, 286 insertions(+), 247 deletions(-) diff --git a/framework/fel/java/plugins/tool-mcp-server/README.md b/framework/fel/java/plugins/tool-mcp-server/README.md index f9d5694e4..cbb1b4f84 100644 --- a/framework/fel/java/plugins/tool-mcp-server/README.md +++ b/framework/fel/java/plugins/tool-mcp-server/README.md @@ -10,212 +10,239 @@ ## 架构概览 -### DefaultMcpServer 的双 Bean 管理 +### 核心组件关系 -`DefaultMcpServer` 是 MCP Server 的核心实现类,它同时管理两个 MCP 同步服务器 Bean: +本插件提供了两种独立的 MCP 服务器实例,分别支持不同的传输协议: -1. **McpSyncSseServer** - 用于 SSE (Server-Sent Events) 传输 -2. **McpSyncStreamableServer** - 用于 Streamable 传输 +1. **McpSseServer** - 基于 SSE 传输的服务器实例 +2. **McpStreamableServer** - 基于 Streamable 传输的服务器实例 -这两个 Bean 分别由 `McpSseServerConfig` 和 `McpStreamableServerConfig` 配置类创建,并通过 `@Fit(alias = "...")` 注解进行区分注入: +每个服务器实例由以下三个核心组件构成: -```java -// McpSseServerConfig.java -@Bean("McpSyncSseServer") -public McpSyncServer mcpSyncSseServer(...) { ... } - -// McpStreamableServerConfig.java -@Bean("McpSyncStreamableServer") -public McpSyncServer mcpSyncStreamableServer(...) { ... } - -// FitMcpServer.java -public DefaultMcpServer(ToolExecuteService toolExecuteService, - @Fit(alias = "McpSyncSseServer") McpSyncServer mcpSyncSseServer, - @Fit(alias = "McpSyncStreamableServer") McpSyncServer mcpSyncStreamableServer) { - ... -} +``` +配置类 (McpSseServerConfig / McpStreamableServerConfig) + │ + ├─> TransportProvider (传输层实现) + │ ├─> FitMcpSseServerTransportProvider + │ └─> FitMcpStreamableServerTransportProvider + │ + ├─> McpSyncServer (MCP SDK 提供的同步服务器) + │ + └─> FitMcpServer (服务器的Fit接口包装,实现工具注册和执行) ``` -`DefaultMcpServer` 实现了 `McpServer` 和 `ToolChangedObserver` 接口,负责: -- 统一管理两个传输类型的工具注册和移除 -- 确保两个 MCP 同步服务器保持工具列表同步 -- 处理工具执行请求并返回结果 -- 通知工具变更观察者 +### FitMcpServer - Fit接口的MCP服务器 ---- +`FitMcpServer` 是连接 FIT 工具系统与 MCP SDK服务器的核心类,主要职责包括: -## FitMcpStreamableServerTransportProvider 类的作用和职责 +- **工具观察**: 实现 `ToolChangedObserver` 接口,监听工具的添加和移除 +- **工具注册**: 将 FIT 工具转换为 MCP 工具规范并注册到 MCP 服务器 +- **工具执行**: 处理来自 MCP 客户端的工具调用请求 +- **生命周期管理**: 在服务销毁时自动注销观察者 -`FitMcpStreamableServerTransportProvider` 是 MCP 服务端传输层的核心实现类,负责: +每个 `FitMcpServer` 实例持有一个 `McpSyncServer`,通过配置类注入。两个独立的实例(SSE 和 Streamable)分别管理各自的工具列表和执行逻辑。 -1. **HTTP 端点处理**: 处理 GET、POST、DELETE 请求,实现 MCP 协议的 HTTP 传输层 -2. **会话管理**: 管理客户端会话的生命周期(创建、维护、销毁) -3. **SSE 通信**: 通过 Server-Sent Events (SSE) 实现服务端到客户端的实时消息推送 -4. **消息序列化**: 处理 JSON-RPC 消息的序列化和反序列化 -5. **连接保活**: 支持可选的 Keep-Alive 机制 -6. **优雅关闭**: 支持服务的优雅关闭和资源清理 +### FitMcpServerTransportProvider - 传输层基类 ---- +`FitMcpServerTransportProvider` 是一个抽象基类,为 SSE 和 Streamable 两种传输方式提供通用功能: -## 类结构概览 +**通用职责**: +- **会话管理**: 维护客户端会话的生命周期(创建、存储、销毁) +- **消息序列化**: 使用 `McpJsonMapper` 处理 JSON-RPC 消息的序列化和反序列化 +- **上下文提取**: 从 HTTP 请求中提取传输上下文信息 +- **Keep-Alive**: 支持可选的连接保活机制 +- **优雅关闭**: 提供服务优雅关闭和资源清理 -### 主要成员变量 +**成员变量**: +- `jsonMapper` - JSON 序列化器 +- `contextExtractor` - 上下文提取器 +- `keepAliveScheduler` - Keep-Alive 调度器 +- `sessions` - 会话映射表 (ConcurrentHashMap) +- `isClosing` - 关闭标志 -| 变量名 | 类型 | 来源 | 说明 | -|----------------------|----------------------------------------------------------|------------|---------------------------------| -| `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 请求(会话删除) | +## 传输层实现 -### 重构后的辅助方法 +### SSE 传输方式 -为提高代码可读性和可维护性,从原本的 `handleGet()`、`handlePost()`、`handleDelete()` 方法中抽取了以下辅助方法: +`FitMcpSseServerTransportProvider` 基于 MCP SDK 的 `HttpServletSseServerTransportProvider` 改造,提供基本的 SSE 传输实现。 -#### 验证请求合法性的方法 +#### 端点配置 -| 方法名 | 说明 | -|-------------------------------|----------------------------------------------------------| -| `validateGetAcceptHeaders()` | 验证 GET 请求的 Accept 头,确保包含 `text/event-stream` | -| `validatePostAcceptHeaders()` | 验证 POST 请求的 Accept 头,确保包含 `text/event-stream` 和 `application/json` | -| `validateRequestSessionId()` | 验证请求的 `mcp-session-id` 头是否存在,以及对应的会话是否存在 | +- **GET `/mcp/sse`**: 建立 SSE 连接,用于服务端向客户端推送消息 +- **POST `/mcp/message`**: 接收客户端发送的 JSON-RPC 消息 -#### 根据请求类型调用处理逻辑的方法 +#### 特点 -| 方法名 | 处理的请求类型 | 说明 | -|---------------------------------|---------|------------------------------------------| -| `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 流式响应 | +- **会话类型**: 使用 `McpServerSession` 管理客户端会话 +- **会话创建**: 在 GET 请求时创建会话,生成唯一的 session ID +- **协议版本**: 仅支持 `MCP_2024_11_05` +- **简洁设计**: 适合简单的服务端到客户端推送场景 -### 内部类 +#### 请求处理流程 -| 类名 | 来源 | 说明 | -|------------------------------------|------------|-----------------------------| -| `FitStreamableMcpSessionTransport` | **FIT 改造** | 用于SSE 会话`sendMessage()`传输实现 | -| `Builder` | SDK 原始 | 构建器模式 | +**GET 请求**: +1. 检查服务器是否正在关闭 +2. 验证 Accept 头是否包含 `text/event-stream` +3. 提取传输上下文 +4. 生成会话 ID 并创建新会话 +5. 建立 SSE 监听流,持续推送消息 + +**POST 请求**: +1. 检查服务器是否正在关闭 +2. 验证 Accept 头包含 `application/json` +3. 验证 `mcp-session-id` 头及会话存在性 +4. 提取传输上下文 +5. 反序列化 JSON-RPC 消息并转发给会话处理 + +#### 内部实现 + +- **Transport 类**: `FitSseMcpSessionTransport` +- **职责**: 封装 SSE 消息发送逻辑,通过 `Emitter` 发送消息 --- -## SDK 原始逻辑 +### Streamable 传输方式 -以下是从 MCP SDK 的 `HttpServletStreamableServerTransportProvider` 类保留的原始逻辑: +`FitMcpStreamableServerTransportProvider` 基于 MCP SDK 的 `HttpServletStreamableServerTransportProvider` 改造,提供功能更丰富的传输实现。 -### 1. 会话管理核心逻辑 +#### 端点配置 -```java -private final Map sessions = new ConcurrentHashMap<>(); -``` +- **GET `/mcp/streamable`**: 建立 SSE 连接或重放消息 +- **POST `/mcp/streamable`**: 处理初始化请求和其他 JSON-RPC 消息 +- **DELETE `/mcp/streamable`**: 删除指定会话 -- 使用 `ConcurrentHashMap` 存储活跃会话 -- 会话以 `mcp-session-id` 作为键 +#### 特点 -### 2. 会话工厂设置 +- **会话类型**: 使用 `McpStreamableServerSession` 管理客户端会话 +- **会话创建**: 在 POST 初始化请求时创建会话 +- **协议版本**: 支持 `MCP_2024_11_05`、`MCP_2025_03_26`、`MCP_2025_06_18` +- **消息重放**: 支持断线重连后恢复错过的消息(通过 `Last-Event-ID`) +- **会话管理**: 提供显式的会话删除机制 +- **功能完整**: 适合需要完整会话管理的复杂场景 -```java -public void setSessionFactory(McpStreamableServerSession.Factory sessionFactory) { - this.sessionFactory = sessionFactory; -} -``` +#### 请求处理流程 -- 由外部设置会话工厂,用于创建新会话 +**GET 请求**: +1. 检查服务器是否正在关闭 +2. 验证 Accept 头是否包含 `text/event-stream` +3. 验证 `mcp-session-id` 头及会话存在性 +4. 提取传输上下文 +5. 检查是否为重放请求(`Last-Event-ID` 头): + - **重放模式**: 重放错过的消息 + - **监听模式**: 建立新的 SSE 监听流 + +**POST 请求**: +1. 检查服务器是否正在关闭 +2. 验证 Accept 头包含 `text/event-stream` 和 `application/json` +3. 提取传输上下文 +4. 反序列化 JSON-RPC 消息 +5. 判断消息类型: + - **初始化请求**: 创建新会话并返回初始化结果 + - **其他消息**: 验证会话后分发处理(响应/通知/请求) -### 3. 客户端通知 +**DELETE 请求**: +1. 检查服务器是否正在关闭 +2. 检查是否禁用 DELETE 操作 +3. 验证 `mcp-session-id` 头及会话存在性 +4. 提取传输上下文 +5. 删除会话并清理资源 -```java -public Mono notifyClients(String method, Object params) { - // ... 广播逻辑 -} -``` +#### 辅助方法 -- 向所有活跃会话并行发送通知 -- 使用 `parallelStream()` 提高效率 -- 单个会话失败不影响其他会话 +为提高代码可读性,从请求处理方法中抽取了以下辅助方法: -### 4. 关闭逻辑 +**验证类**: +- `validateGetAcceptHeaders()` - 验证 GET 请求的 Accept 头 +- `validatePostAcceptHeaders()` - 验证 POST 请求的 Accept 头 +- `validateRequestSessionId()` - 验证会话 ID -```java -public Mono closeGracefully() { - this.isClosing = true; - // ... 关闭所有会话 - // ... 关闭 keep-alive 调度器 -} -``` +**处理类**: +- `handleReplaySseRequest()` - 处理消息重放请求 +- `handleEstablishSseRequest()` - 处理 SSE 连接建立 +- `handleInitializeRequest()` - 处理初始化请求 +- `handleJsonRpcMessage()` - 分流非初始化消息 +- `handleJsonRpcResponse()` - 处理 JSON-RPC 响应 +- `handleJsonRpcNotification()` - 处理 JSON-RPC 通知 +- `handleJsonRpcRequest()` - 处理 JSON-RPC 请求 -- 设置关闭标志 -- 关闭所有活跃会话 -- 清理资源 +#### 内部实现 -## FIT 框架改造核心逻辑 +- **Transport 类**: `FitStreamableMcpSessionTransport` +- **职责**: 封装 SSE 消息发送逻辑,支持消息重放和连接状态检查 -以下是为适配 FIT 框架而新增或改造的部分: +--- -### 1. HTTP 端点处理核心流程(核心改造) +## 传输方式对比 -- 请求/响应对象类型变更: - - `HttpServletRequest` → `HttpClassicServerRequest` - - `HttpServletResponse` → `HttpClassicServerResponse` -- 返回类型改为通用的 `Object`,支持多种返回形式 +### 功能对比表 -#### a. GET 请求处理流程 +| 特性 | SSE | Streamable | +|------|-----|------------| +| **端点路径** | GET `/mcp/sse`
    POST `/mcp/message` | GET/POST/DELETE `/mcp/streamable` | +| **支持的协议版本** | `MCP_2024_11_05` | `MCP_2024_11_05`
    `MCP_2025_03_26`
    `MCP_2025_06_18` | +| **会话类型** | `McpServerSession` | `McpStreamableServerSession` | +| **会话创建时机** | GET 请求时 | POST 初始化请求时 | +| **消息重放** | ❌ 不支持 | ✅ 支持 (通过 `Last-Event-ID`) | +| **显式会话删除** | ❌ 无 DELETE 端点 | ✅ 支持 DELETE 请求 | +| **Keep-Alive** | ✅ 支持 | ✅ 支持 | +| **代码复杂度** | 较低 | 较高 | +| **适用场景** | 简单的单向推送 | 复杂的双向通信和会话管理 | -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 请求处理流程 +**使用 SSE 方式**,当你需要: +- 简单的服务端到客户端消息推送 +- 最小化的会话管理开销 +- 单一协议版本支持 -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()`** +**使用 Streamable 方式**,当你需要: +- 完整的会话生命周期管理 +- 断线重连后的消息重放功能 +- 支持多个 MCP 协议版本 +- 显式的会话清理机制 -#### c. DELETE 请求处理流程 +--- -1. 检查服务器是否正在关闭 -2. 检查是否禁用 DELETE 操作 -3. **调用 `validateRequestSessionId()`** - 验证 `mcp-session-id` 头及会话存在性 -4. 提取 `transportContext` 上下文 -5. 获取会话 ID 和会话对象 -6. 删除会话并从会话映射表中移除 +## SDK 改造说明 + +以下是将 MCP SDK 适配到 FIT 框架的通用改造点,两种传输方式均涉及这些改造(详细实现可参考各自的 TransportProvider 类)。 + +### 1. HTTP 请求/响应对象 + +**SDK 原始**: +```java +HttpServletRequest request +HttpServletResponse response +``` + +**FIT 改造**: +```java +HttpClassicServerRequest request +HttpClassicServerResponse response +``` -### 2. SSE 实现改造(核心改造) +**HTTP 头操作**: +```java +// 获取 Header +String accept = request.headers().first(MessageHeaderNames.ACCEPT).orElse(""); +String sessionId = request.headers().first(HttpHeaders.MCP_SESSION_ID).orElse(""); +boolean hasSessionId = request.headers().contains(HttpHeaders.MCP_SESSION_ID); -**原始 SDK**: +// 设置 Header +response.headers().set("Content-Type", MimeType.APPLICATION_JSON.value()); +response.headers().set(HttpHeaders.MCP_SESSION_ID, sessionId); +// 设置状态码 +response.statusCode(HttpResponseStatus.OK.statusCode()); +``` + +### 2. SSE 事件流实现 + +**SDK 原始**: ```java SseEmitter sseEmitter = new SseEmitter(); sseEmitter.send(SseEmitter.event() @@ -225,157 +252,168 @@ sseEmitter.send(SseEmitter.event() sseEmitter.complete(); ``` -**FIT 框架改造**: - +**FIT 改造**: ```java -// 使用 Choir 和 Emitter 实现 SSE -Choir.create(emitter -> { - // 创建sessionTransport类,用于调用emitter发送消息 +return Choir.create(emitter -> { + // 创建 Transport 封装 emitter FitStreamableMcpSessionTransport sessionTransport = new FitStreamableMcpSessionTransport(sessionId, emitter, response); - // session的逻辑是SDK原有的,里面会调用sessionTransport发送事件流 + // 调用 SDK 的 session 逻辑发送消息 session.responseStream(jsonrpcRequest, sessionTransport) .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)) .block(); - // 监听 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(); - } + @Override + public void onEmittedData(TextEvent data) { } + + @Override + public void onCompleted() { + listeningStream.close(); + } + + @Override + public void onFailed(Exception cause) { + listeningStream.close(); + } }); }); ``` **关键变化**: +- 使用 `Choir` 替代 `SseEmitter` +- 使用 `Emitter` 发送事件 +- 使用 `Emitter.Observer` 监听生命周期 -- 使用 `Choir` 返回事件流 -- 使用 `Emitter` 替代 `SseEmitter` 的发送方法 -- 使用 `Emitter.Observer` 监听 SSE 生命周期事件 - -### 3. HTTP 响应处理改造 - -**FIT 特有的响应方式**: - -#### 返回纯文本 +### 3. HTTP 响应创建 +**返回纯文本**: ```java response.statusCode(HttpResponseStatus.BAD_REQUEST.statusCode()); -return Entity.createText(response, "Session ID required in mcp-session-id header"); +return Entity.createText(response, "Session ID required"); ``` -#### 返回 JSON 对象 - +**返回 JSON 对象**: ```java response.statusCode(HttpResponseStatus.NOT_FOUND.statusCode()); return Entity.createObject(response, McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS) - .message("Session not found: "+sessionId) + .message("Session not found: " + sessionId) .build()); ``` -#### 返回 SSE 流(重要改造) - +**返回 SSE 流**: ```java -return Choir. create(emitter ->{ - // emitter封装在sessionTransport中,被session调用 +return Choir.create(emitter -> { emitter.emit(textEvent); }); ``` -### 4. HTTP 头处理改造 - -**FIT 框架的 Headers API**: +### 4. Transport 实现类 -```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(""); +两种传输方式都实现了内部 Transport 类,封装 SSE 消息发送逻辑: -// 设置 Header -response.headers().set("Content-Type",MimeType.APPLICATION_JSON.value()); -response.headers().set(HttpHeaders.MCP_SESSION_ID, sessionId); +**核心职责**: +- 通过 `Emitter` 发送 SSE 消息 +- 在 `close()` 时关闭 Emitter +- 发送前检查连接是否活跃 -// 设置状态码 -response.statusCode(HttpResponseStatus.OK.statusCode()); +**连接检查**: +```java +@Override +public void sendMessage(JSONRPCMessage message) { + // 检查连接是否仍然活跃 + if (!this.response.isActive()) { + logger.warn("[SSE] Connection inactive, session: {}", this.sessionId); + this.close(); + return; + } + + // 发送消息 + String messageJson = jsonMapper.writeValueAsString(message); + Event event = new Event(messageId, "message", messageJson); + this.emitter.emit(new TextEvent(event.toString())); +} ``` -**变化**: +--- -- 使用 `request.headers().first(name).orElse(default)` 获取单个 Header -- 使用 `request.headers().contains(name)` 检查 Header 是否存在 -- 使用 FIT 的 `MessageHeaderNames` 和 `MimeType` 常量 -- 使用 `HttpResponseStatus` 枚举设置状态码 +## SDK 保留逻辑 -### 5. 内部类 Transport 实现 +以下是从 MCP SDK 保留的核心逻辑,两种传输方式共享。 -`FitStreamableMcpSessionTransport` 类的核心职责是发送SSE事件: +### 1. 会话存储 + +```java +private final Map sessions = new ConcurrentHashMap<>(); +``` -- `sendmessage()`方法通过`Emitter` 发送SSE消息到客户端 -- 保存了当前会话的事件的`Emitter`,负责close时关闭`Emitter` +- 使用线程安全的 `ConcurrentHashMap` 存储会话 +- 键为 `mcp-session-id`,值为会话对象 -- SSE的`Emitter`感知不到GET连接是否断开,因此在`sendmessage()`发送前检查GET连接是否活跃 +### 2. 会话工厂 ```java -// 在发送消息前检查连接是否仍然活跃 -if(!this.response.isActive()){ - logger.warn("[SSE] Connection inactive detected while sending message for session: {}", - this.sessionId); - this.close(); - return; +public void setSessionFactory(S.Factory sessionFactory) { + this.sessionFactory = sessionFactory; } ``` ---- +- 由外部(MCP SDK)设置会话工厂 +- 用于创建新会话实例 -## FitMcpSseServerTransportProvider 简要说明 +### 3. 客户端通知 -`FitMcpSseServerTransportProvider` 是基于 MCP SDK 中的 `HttpServletSseServerTransportProvider` 改造而来的 FIT 框架实现,用于提供 MCP SSE 传输层。 +```java +public Mono notifyClients(String method, Object params) { + // 并行向所有活跃会话发送通知 + sessions.values().parallelStream() + .forEach(session -> session.sendNotification(method, params)); +} +``` + +- 向所有活跃会话并行发送通知 +- 单个会话失败不影响其他会话 + +### 4. 优雅关闭 -### 与 Streamable 的主要区别 +```java +public Mono closeGracefully() { + this.isClosing = true; + // 关闭所有会话 + // 关闭 keep-alive 调度器 + // 清理资源 +} +``` -`FitMcpSseServerTransportProvider` 的实现与 `FitMcpStreamableServerTransportProvider` 非常相似,主要区别在于: +- 设置关闭标志,拒绝新请求 +- 关闭所有活跃会话 +- 清理调度器和其他资源 -1. **端点路径**: - - SSE: `/mcp/sse` (GET) 和 `/mcp/message` (POST) - - Streamable: `/mcp/streamable` (GET/POST/DELETE) +--- -2. **协议版本支持**: - - SSE: 仅支持 `MCP_2024_11_05` - - Streamable: 支持 `MCP_2024_11_05`、`MCP_2025_03_26`、`MCP_2025_06_18` +## 配置说明 -3. **请求处理**: - - SSE: GET 请求用于建立 SSE 连接,POST 请求用于发送 JSON-RPC 消息 - - Streamable: GET 请求用于建立 SSE 连接或重放消息,POST 请求处理初始化和其他 JSON-RPC 消息,DELETE 请求用于删除会话 +### SSE 配置类 (McpSseServerConfig) -4. **会话管理**: - - SSE: 使用 `McpServerSession`,会话通过 GET 请求建立 - - Streamable: 使用 `McpStreamableServerSession`,会话通过 POST 初始化请求建立 +创建三个组件: +1. `FitMcpSseServerTransportProvider` - 传输层 +2. `McpSyncSseServer` - MCP 同步服务器 +3. `McpSseServer` - FIT 工具服务器 -### 核心改造点 +### Streamable 配置类 (McpStreamableServerConfig) -与 Streamable 版本类似,SSE 版本也进行了以下 FIT 框架改造: +创建三个组件: +1. `FitMcpStreamableServerTransportProvider` - 传输层 +2. `McpSyncStreamableServer` - MCP 同步服务器 +3. `McpStreamableServer` - FIT 工具服务器 -- 使用 `HttpClassicServerRequest` 和 `HttpClassicServerResponse` 替代 Servlet API -- 使用 `Choir` 和 `Emitter` 实现 SSE 事件流 -- 使用 FIT 的 HTTP 注解 (`@GetMapping`, `@PostMapping`) 处理请求 -- 使用 `Entity.createText()` 和 `Entity.createObject()` 创建响应 +### 配置参数 -详细的实现细节可以参考 `FitMcpStreamableServerTransportProvider` 的相关章节。 +- `mcp.server.ping.interval-seconds` - Keep-Alive 间隔(秒) +- `mcp.server.request.timeout-seconds` - 请求超时时间(秒) +- `mcp.server.streamable.disallow-delete` - 是否禁用 DELETE 请求(仅 Streamable) --- @@ -391,4 +429,5 @@ if(!this.response.isActive()){ | 日期 | 更新内容 | 负责人 | |----------|---------------------------------|-----| | 2025-11-04 | 初始版本,从 SDK 改造为 FIT 框架实现 | 黄可欣 | -| 2025-11-05 | 代码重构,提取9个辅助方法提高可读性和可维护性 | 黄可欣 | \ No newline at end of file +| 2025-11-05 | 代码重构,提取辅助方法提高可读性和可维护性 | 黄可欣 | +| 2025-11-21 | 文档重构,调整结构使其与代码保持一致,简化技术术语 | 黄可欣 | From 3a1343014ab9914827a98b878323285a0ceba25c 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, 21 Nov 2025 11:08:11 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=BAfalse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/tool-mcp-server/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 64922166e..8b00e28a6 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 @@ -10,4 +10,4 @@ mcp: ping: interval-seconds: 30 streamable: - disallow-delete: False \ No newline at end of file + disallow-delete: false \ No newline at end of file