Skip to content

Commit 83da8ec

Browse files
authored
[fel] add notification/tools/list_changed method in MCP server (#136)
* [fit] add notification/tools/list_changed method in MCP server * [fel] add unit test for MCP server
1 parent 17a0e84 commit 83da8ec

File tree

9 files changed

+312
-5
lines changed

9 files changed

+312
-5
lines changed

framework/fel/java/plugins/tool-mcp-server/pom.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,20 @@
3232
</dependency>
3333

3434
<!-- Test -->
35+
<dependency>
36+
<groupId>org.junit.jupiter</groupId>
37+
<artifactId>junit-jupiter</artifactId>
38+
<scope>test</scope>
39+
</dependency>
40+
<dependency>
41+
<groupId>org.mockito</groupId>
42+
<artifactId>mockito-core</artifactId>
43+
<scope>test</scope>
44+
</dependency>
3545
<dependency>
3646
<groupId>org.assertj</groupId>
3747
<artifactId>assertj-core</artifactId>
48+
<scope>test</scope>
3849
</dependency>
3950
</dependencies>
4051

framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpController.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,15 @@
4848
* @since 2025-05-13
4949
*/
5050
@Component
51-
public class McpController {
51+
public class McpController implements McpServer.ToolsChangedObserver {
5252
private static final Logger log = Logger.get(McpController.class);
5353
private static final String MESSAGE_PATH = "/mcp/message";
5454
private static final String EVENT_ENDPOINT = "endpoint";
5555
private static final String EVENT_MESSAGE = "message";
5656
private static final String METHOD_INITIALIZE = "initialize";
5757
private static final String METHOD_TOOLS_LIST = "tools/list";
5858
private static final String METHOD_TOOLS_CALL = "tools/call";
59+
private static final String METHOD_NOTIFICATION_TOOLS_CHANGED = "notifications/tools/list_changed";
5960
private static final String RESPONSE_OK = StringUtils.EMPTY;
6061

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

8385
this.methodHandlers.put(METHOD_INITIALIZE, new InitializeHandler(mcpServer));
8486
this.methodHandlers.put(METHOD_TOOLS_LIST, new ToolListHandler(mcpServer));
@@ -170,4 +172,16 @@ public Object receiveMcpMessage(@RequestQuery(name = "sessionId") String session
170172
log.info("Send MCP message. [response={}]", serialized);
171173
return RESPONSE_OK;
172174
}
175+
176+
@Override
177+
public void onToolsChanged() {
178+
JsonRpcEntity notification = new JsonRpcEntity();
179+
notification.setMethod(METHOD_NOTIFICATION_TOOLS_CHANGED);
180+
String serialized = this.serializer.serialize(notification);
181+
this.emitters.forEach((sessionId, emitter) -> {
182+
TextEvent textEvent = TextEvent.custom().id(sessionId).event(EVENT_MESSAGE).data(serialized).build();
183+
emitter.emit(textEvent);
184+
log.info("Send MCP notification: tools changed. [sessionId={}]", sessionId);
185+
});
186+
}
173187
}

framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/McpServer.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,21 @@ public interface McpServer {
4040
* @return The tool result as a {@link Object}.
4141
*/
4242
Object callTool(String name, Map<String, Object> arguments);
43+
44+
/**
45+
* Registers MCP Server Tools Changed Observer.
46+
*
47+
* @param observer The MCP Server Tools Changed Observer as a {@link ToolsChangedObserver}.
48+
*/
49+
void registerToolsChangedObserver(ToolsChangedObserver observer);
50+
51+
/**
52+
* Represents the MCP Server Tools Changed Observer.
53+
*/
54+
interface ToolsChangedObserver {
55+
/**
56+
* Called when MCP Server Tools changed.
57+
*/
58+
void onToolsChanged();
59+
}
4360
}

framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/InitializeHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public class InitializeHandler extends AbstractMessageHandler<InitializeHandler.
2626
* Constructs a new instance of the InitializeHandler class.
2727
*
2828
* @param mcpServer The MCP server instance used to retrieve server information during request handling.
29-
* @throws IllegalStateException If {@code mcpServer} is null.
29+
* @throws IllegalArgumentException If {@code mcpServer} is null.
3030
*/
3131
public InitializeHandler(McpServer mcpServer) {
3232
super(InitializeRequest.class);

framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolCallHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public class ToolCallHandler extends AbstractMessageHandler<ToolCallHandler.Tool
3838
*
3939
* @param mcpServer The MCP server instance used to invoke tools during request handling.
4040
* @param jsonSerializer The serializer used to convert non-string results into JSON strings.
41-
* @throws IllegalStateException If {@code mcpServer} or {@code jsonSerializer} is null.
41+
* @throws IllegalArgumentException If {@code mcpServer} or {@code jsonSerializer} is null.
4242
*/
4343
public ToolCallHandler(McpServer mcpServer, ObjectSerializer jsonSerializer) {
4444
super(ToolCallRequest.class);

framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/handler/ToolListHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public class ToolListHandler extends AbstractMessageHandler<ToolListHandler.Tool
2828
* Constructs a new instance of the ToolListHandler class.
2929
*
3030
* @param mcpServer The MCP server instance used to retrieve the list of tools during request handling.
31-
* @throws IllegalStateException If {@code mcpServer} is null.
31+
* @throws IllegalArgumentException If {@code mcpServer} is null.
3232
*/
3333
public ToolListHandler(McpServer mcpServer) {
3434
super(ToolListRequest.class);

framework/fel/java/plugins/tool-mcp-server/src/main/java/modelengine/fel/tool/mcp/server/support/DefaultMcpServer.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import modelengine.fitframework.util.MapUtils;
1919
import modelengine.fitframework.util.StringUtils;
2020

21+
import java.util.ArrayList;
2122
import java.util.List;
2223
import java.util.Map;
2324
import java.util.concurrent.ConcurrentHashMap;
@@ -34,12 +35,13 @@ public class DefaultMcpServer implements McpServer, ToolChangedObserver {
3435

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

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

77+
@Override
78+
public void registerToolsChangedObserver(ToolsChangedObserver observer) {
79+
if (observer != null) {
80+
this.toolsChangedObservers.add(observer);
81+
}
82+
}
83+
7584
@Override
7685
public void onToolAdded(String name, String description, Map<String, Object> schema) {
7786
if (StringUtils.isBlank(name)) {
@@ -92,6 +101,7 @@ public void onToolAdded(String name, String description, Map<String, Object> sch
92101
tool.setInputSchema(schema);
93102
this.tools.put(name, tool);
94103
log.info("Tool added to MCP server. [toolName={}, description={}, schema={}]", name, description, schema);
104+
this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged);
95105
}
96106

97107
@Override
@@ -102,5 +112,6 @@ public void onToolRemoved(String name) {
102112
}
103113
this.tools.remove(name);
104114
log.info("Tool removed from MCP server. [toolName={}]", name);
115+
this.toolsChangedObservers.forEach(ToolsChangedObserver::onToolsChanged);
105116
}
106117
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) 2025 Huawei Technologies Co., Ltd. All rights reserved.
3+
* This file is a part of the ModelEngine Project.
4+
* Licensed under the MIT License. See License.txt in the project root for license information.
5+
*--------------------------------------------------------------------------------------------*/
6+
7+
package modelengine.fel.tool.mcp.server;
8+
9+
import static org.assertj.core.api.Assertions.assertThat;
10+
import static org.assertj.core.api.Assertions.catchThrowableOfType;
11+
import static org.mockito.Mockito.mock;
12+
13+
import modelengine.fitframework.serialization.ObjectSerializer;
14+
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.DisplayName;
17+
import org.junit.jupiter.api.Nested;
18+
import org.junit.jupiter.api.Test;
19+
20+
/**
21+
* Unit test for {@link McpController}.
22+
*
23+
* @author 季聿阶
24+
* @since 2025-05-20
25+
*/
26+
@DisplayName("Unit tests for McpController")
27+
public class McpControllerTest {
28+
private ObjectSerializer objectSerializer;
29+
private McpServer mcpServer;
30+
private String baseUrl;
31+
32+
@BeforeEach
33+
void setup() {
34+
this.objectSerializer = mock(ObjectSerializer.class);
35+
this.mcpServer = mock(McpServer.class);
36+
this.baseUrl = "http://localhost:8080";
37+
}
38+
39+
@Nested
40+
@DisplayName("Constructor Tests")
41+
class GivenConstructor {
42+
@Test
43+
@DisplayName("Should throw exception when base URL is null or blank")
44+
void shouldThrowExceptionWhenBaseUrlIsNullOrEmpty() {
45+
// Null
46+
var exception1 = catchThrowableOfType(IllegalArgumentException.class,
47+
() -> new McpController(null, objectSerializer, mcpServer));
48+
assertThat(exception1).hasMessage("The base URL for MCP server cannot be blank.");
49+
50+
// Blank
51+
var exception2 = catchThrowableOfType(IllegalArgumentException.class,
52+
() -> new McpController("", objectSerializer, mcpServer));
53+
assertThat(exception2).hasMessage("The base URL for MCP server cannot be blank.");
54+
}
55+
56+
@Test
57+
@DisplayName("Should throw exception when serializer is null")
58+
void shouldThrowExceptionWhenSerializerIsNull() {
59+
var exception = catchThrowableOfType(IllegalArgumentException.class,
60+
() -> new McpController(baseUrl, null, mcpServer));
61+
assertThat(exception).hasMessage("The json serializer cannot be null.");
62+
}
63+
64+
@Test
65+
@DisplayName("Should throw exception when mcpServer is null")
66+
void shouldThrowExceptionWhenMcpServerIsNull() {
67+
var exception = catchThrowableOfType(IllegalArgumentException.class,
68+
() -> new McpController(baseUrl, objectSerializer, null));
69+
assertThat(exception).hasMessage("The MCP server cannot be null.");
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)