Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions framework/fel/java/plugins/tool-mcp-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,20 @@
</dependency>

<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,15 @@
* @since 2025-05-13
*/
@Component
public class McpController {
public class McpController implements McpServer.ToolsChangedObserver {
private static final Logger log = Logger.get(McpController.class);
private static final String MESSAGE_PATH = "/mcp/message";
private static final String EVENT_ENDPOINT = "endpoint";
private static final String EVENT_MESSAGE = "message";
private static final String METHOD_INITIALIZE = "initialize";
private static final String METHOD_TOOLS_LIST = "tools/list";
private static final String METHOD_TOOLS_CALL = "tools/call";
private static final String METHOD_NOTIFICATION_TOOLS_CHANGED = "notifications/tools/list_changed";
private static final String RESPONSE_OK = StringUtils.EMPTY;

private final Map<String, Emitter<TextEvent>> emitters = new ConcurrentHashMap<>();
Expand All @@ -79,6 +80,7 @@ public McpController(@Value("${base-url}") String baseUrl, @Fit(alias = "json")
this.baseUrl = notBlank(baseUrl, "The base URL for MCP server cannot be blank.");
this.serializer = notNull(serializer, "The json serializer cannot be null.");
notNull(mcpServer, "The MCP server cannot be null.");
mcpServer.registerToolsChangedObserver(this);

this.methodHandlers.put(METHOD_INITIALIZE, new InitializeHandler(mcpServer));
this.methodHandlers.put(METHOD_TOOLS_LIST, new ToolListHandler(mcpServer));
Expand Down Expand Up @@ -170,4 +172,16 @@ public Object receiveMcpMessage(@RequestQuery(name = "sessionId") String session
log.info("Send MCP message. [response={}]", serialized);
return RESPONSE_OK;
}

@Override
public void onToolsChanged() {
JsonRpcEntity notification = new JsonRpcEntity();
notification.setMethod(METHOD_NOTIFICATION_TOOLS_CHANGED);
String serialized = this.serializer.serialize(notification);
this.emitters.forEach((sessionId, emitter) -> {
TextEvent textEvent = TextEvent.custom().id(sessionId).event(EVENT_MESSAGE).data(serialized).build();
emitter.emit(textEvent);
log.info("Send MCP notification: tools changed. [sessionId={}]", sessionId);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,21 @@ public interface McpServer {
* @return The tool result as a {@link Object}.
*/
Object callTool(String name, Map<String, Object> arguments);

/**
* 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class InitializeHandler extends AbstractMessageHandler<InitializeHandler.
* Constructs a new instance of the InitializeHandler class.
*
* @param mcpServer The MCP server instance used to retrieve server information during request handling.
* @throws IllegalStateException If {@code mcpServer} is null.
* @throws IllegalArgumentException If {@code mcpServer} is null.
*/
public InitializeHandler(McpServer mcpServer) {
super(InitializeRequest.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class ToolCallHandler extends AbstractMessageHandler<ToolCallHandler.Tool
*
* @param mcpServer The MCP server instance used to invoke tools during request handling.
* @param jsonSerializer The serializer used to convert non-string results into JSON strings.
* @throws IllegalStateException If {@code mcpServer} or {@code jsonSerializer} is null.
* @throws IllegalArgumentException If {@code mcpServer} or {@code jsonSerializer} is null.
*/
public ToolCallHandler(McpServer mcpServer, ObjectSerializer jsonSerializer) {
super(ToolCallRequest.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class ToolListHandler extends AbstractMessageHandler<ToolListHandler.Tool
* Constructs a new instance of the ToolListHandler class.
*
* @param mcpServer The MCP server instance used to retrieve the list of tools during request handling.
* @throws IllegalStateException If {@code mcpServer} is null.
* @throws IllegalArgumentException If {@code mcpServer} is null.
*/
public ToolListHandler(McpServer mcpServer) {
super(ToolListRequest.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import modelengine.fitframework.util.MapUtils;
import modelengine.fitframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
Expand All @@ -34,12 +35,13 @@ public class DefaultMcpServer implements McpServer, ToolChangedObserver {

private final ToolExecuteService toolExecuteService;
private final Map<String, ToolEntity> tools = new ConcurrentHashMap<>();
private final List<ToolsChangedObserver> toolsChangedObservers = new ArrayList<>();

/**
* Constructs a new instance of the DefaultMcpServer class.
*
* @param toolExecuteService The service used to execute tools when handling tool call requests.
* @throws IllegalStateException If {@code toolExecuteService} is null.
* @throws IllegalArgumentException If {@code toolExecuteService} is null.
*/
public DefaultMcpServer(ToolExecuteService toolExecuteService) {
this.toolExecuteService = notNull(toolExecuteService, "The tool execute service cannot be null.");
Expand Down Expand Up @@ -72,6 +74,13 @@ public Object callTool(String name, Map<String, Object> arguments) {
return result;
}

@Override
public void registerToolsChangedObserver(ToolsChangedObserver observer) {
if (observer != null) {
this.toolsChangedObservers.add(observer);
}
}

@Override
public void onToolAdded(String name, String description, Map<String, Object> schema) {
if (StringUtils.isBlank(name)) {
Expand All @@ -92,6 +101,7 @@ public void onToolAdded(String name, String description, Map<String, Object> sch
tool.setInputSchema(schema);
this.tools.put(name, tool);
log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, schema);
this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged);
}

@Override
Expand All @@ -102,5 +112,6 @@ public void onToolRemoved(String name) {
}
this.tools.remove(name);
log.info("Tool removed from MCP server. [toolName={}]", name);
this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved.
* This file is a part of the ModelEngine Project.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

package modelengine.fel.tool.mcp.server;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.Mockito.mock;

import modelengine.fitframework.serialization.ObjectSerializer;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
* Unit test for {@link McpController}.
*
* @author 季聿阶
* @since 2025-05-20
*/
@DisplayName("Unit tests for McpController")
public class McpControllerTest {
private ObjectSerializer objectSerializer;
private McpServer mcpServer;
private String baseUrl;

@BeforeEach
void setup() {
this.objectSerializer = mock(ObjectSerializer.class);
this.mcpServer = mock(McpServer.class);
this.baseUrl = "http://localhost:8080";
}

@Nested
@DisplayName("Constructor Tests")
class GivenConstructor {
@Test
@DisplayName("Should throw exception when base URL is null or blank")
void shouldThrowExceptionWhenBaseUrlIsNullOrEmpty() {
// Null
var exception1 = catchThrowableOfType(IllegalArgumentException.class,
() -> new McpController(null, objectSerializer, mcpServer));
assertThat(exception1).hasMessage("The base URL for MCP server cannot be blank.");

// Blank
var exception2 = catchThrowableOfType(IllegalArgumentException.class,
() -> new McpController("", objectSerializer, mcpServer));
assertThat(exception2).hasMessage("The base URL for MCP server cannot be blank.");
}

@Test
@DisplayName("Should throw exception when serializer is null")
void shouldThrowExceptionWhenSerializerIsNull() {
var exception = catchThrowableOfType(IllegalArgumentException.class,
() -> new McpController(baseUrl, null, mcpServer));
assertThat(exception).hasMessage("The json serializer cannot be null.");
}

@Test
@DisplayName("Should throw exception when mcpServer is null")
void shouldThrowExceptionWhenMcpServerIsNull() {
var exception = catchThrowableOfType(IllegalArgumentException.class,
() -> new McpController(baseUrl, objectSerializer, null));
assertThat(exception).hasMessage("The MCP server cannot be null.");
}
}
}
Loading
Loading