feat(mcp): refactor logging to use exchange for targeted client notifications (#132)

Refactors the MCP logging system to use the exchange mechanism for sending
logging notifications only to specific client sessions rather than broadcasting to all clients.

- Move logging notification delivery from server-wide broadcast to per-session exchange
- Implement per-session minimum logging level tracking and filtering
- Add proper logging level filtering at the exchange level
- Change setLoggingLevel from notification to request/response pattern (breaking change)
- Deprecate global server.loggingNotification in favor of exchange.loggingNotification
- Add SetLevelRequest record to McpSchema
- Add integration test demonstrating filtered logging notifications

Resolves #131

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
Co-authored-by: Dariusz Jędrzejczyk <dariusz.jedrzejczyk@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-04-09 11:15:16 +02:00
commit c88ac937f3
17 changed files with 721 additions and 613 deletions

View file

@ -4,6 +4,7 @@
package io.modelcontextprotocol;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@ -111,27 +112,28 @@ public class WebFluxSseIntegrationTests {
return Mono.just(mock(CallToolResult.class));
});
McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
// Create client without sampling capabilities
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0"))
.build();) {
assertThat(client.initialize()).isNotNull();
assertThat(client.initialize()).isNotNull();
try {
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
try {
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
}
}
server.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testCreateMessageSuccess(String clientType) throws InterruptedException {
// Client
var clientBuilder = clientBuilders.get(clientType);
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
@ -142,13 +144,6 @@ public class WebFluxSseIntegrationTests {
CreateMessageResult.StopReason.STOP_SEQUENCE);
};
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build();
// Server
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
null);
@ -183,15 +178,19 @@ public class WebFluxSseIntegrationTests {
.tools(tool)
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build()) {
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
mcpClient.close();
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
}
mcpServer.close();
}
@ -206,41 +205,42 @@ public class WebFluxSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(rootsRef.get()).isNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
}
mcpClient.close();
mcpServer.close();
}
@ -261,21 +261,21 @@ public class WebFluxSseIntegrationTests {
var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> {
}).tools(tool).build();
// Create client without roots capability
// No roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
try (
// Create client without roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) {
assertThat(mcpClient.initialize()).isNotNull();
assertThat(mcpClient.initialize()).isNotNull();
// Attempt to list roots should fail
try {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
// Attempt to list roots should fail
try {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
}
}
mcpClient.close();
mcpServer.close();
}
@ -285,30 +285,31 @@ public class WebFluxSseIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.initialize()).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
}
mcpClient.close();
mcpServer.close();
}
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testRootsWithMultipleHandlers(String clientType) {
var clientBuilder = clientBuilders.get(clientType);
List<Root> roots = List.of(new Root("uri1://", "root1"));
@ -321,21 +322,21 @@ public class WebFluxSseIntegrationTests {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
}
mcpClient.close();
mcpServer.close();
}
@ -348,28 +349,26 @@ public class WebFluxSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
}
// Close server while subscription is active
mcpServer.close();
// Verify client can handle server closure gracefully
mcpClient.close();
}
// ---------------------------------------
@ -378,9 +377,9 @@ public class WebFluxSseIntegrationTests {
String emptyJsonSchema = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {}
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {}
}
""";
@ -408,19 +407,19 @@ public class WebFluxSseIntegrationTests {
.tools(tool1)
.build();
var mcpClient = clientBuilder.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
}
mcpClient.close();
mcpServer.close();
}
@ -443,13 +442,14 @@ public class WebFluxSseIntegrationTests {
return callResponse;
});
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -458,39 +458,40 @@ public class WebFluxSseIntegrationTests {
.body(String.class);
assertThat(response).isNotBlank();
rootsRef.set(toolsUpdate);
}).build();
}).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(rootsRef.get()).isNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
mcpServer.notifyToolsListChanged();
mcpServer.notifyToolsListChanged();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
// Remove a tool
mcpServer.removeTool("tool1");
// Remove a tool
mcpServer.removeTool("tool1");
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
// Add a new tool
McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), (exchange, request) -> callResponse);
// Add a new tool
McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema),
(exchange, request) -> callResponse);
mcpServer.addTool(tool2);
mcpServer.addTool(tool2);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
}
mcpClient.close();
mcpServer.close();
}
@ -502,12 +503,115 @@ public class WebFluxSseIntegrationTests {
var mcpServer = McpServer.sync(mcpServerTransportProvider).build();
var mcpClient = clientBuilder.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
}
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpServer.close();
}
mcpClient.close();
// ---------------------------------------
// Logging Tests
// ---------------------------------------
@ParameterizedTest(name = "{0} : {displayName} ")
@ValueSource(strings = { "httpclient", "webflux" })
void testLoggingNotification(String clientType) {
// Create a list to store received logging notifications
List<McpSchema.LoggingMessageNotification> receivedNotifications = new ArrayList<>();
var clientBuilder = clientBuilders.get(clientType);
// Create server with a tool that sends logging notifications
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
new McpSchema.Tool("logging-test", "Test logging notifications", emptyJsonSchema),
(exchange, request) -> {
// Create and send notifications with different levels
//@formatter:off
return exchange // This should be filtered out (DEBUG < NOTICE)
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.DEBUG)
.logger("test-logger")
.data("Debug message")
.build())
.then(exchange // This should be sent (NOTICE >= NOTICE)
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.NOTICE)
.logger("test-logger")
.data("Notice message")
.build()))
.then(exchange // This should be sent (ERROR > NOTICE)
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.ERROR)
.logger("test-logger")
.data("Error message")
.build()))
.then(exchange // This should be filtered out (INFO < NOTICE)
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
.logger("test-logger")
.data("Another info message")
.build()))
.then(exchange // This should be sent (ERROR >= NOTICE)
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.ERROR)
.logger("test-logger")
.data("Another error message")
.build()))
.thenReturn(new CallToolResult("Logging test completed", false));
//@formatter:on
});
var mcpServer = McpServer.async(mcpServerTransportProvider)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().tools(true).build())
.tools(tool)
.build();
try (
// Create client with logging notification handler
var mcpClient = clientBuilder.loggingConsumer(notification -> {
receivedNotifications.add(notification);
}).build()) {
// Initialize client
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Set minimum logging level to NOTICE
mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE);
// Call the tool that sends logging notifications
CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of()));
assertThat(result).isNotNull();
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed");
// Wait for notifications to be processed
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
// Should have received 3 notifications (1 NOTICE and 2 ERROR)
assertThat(receivedNotifications).hasSize(3);
// First notification should be NOTICE level
assertThat(receivedNotifications.get(0).level()).isEqualTo(McpSchema.LoggingLevel.NOTICE);
assertThat(receivedNotifications.get(0).logger()).isEqualTo("test-logger");
assertThat(receivedNotifications.get(0).data()).isEqualTo("Notice message");
// Second notification should be ERROR level
assertThat(receivedNotifications.get(1).level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
assertThat(receivedNotifications.get(1).logger()).isEqualTo("test-logger");
assertThat(receivedNotifications.get(1).data()).isEqualTo("Error message");
// Third notification should be ERROR level
assertThat(receivedNotifications.get(2).level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
assertThat(receivedNotifications.get(2).logger()).isEqualTo("test-logger");
assertThat(receivedNotifications.get(2).data()).isEqualTo("Another error message");
});
}
mcpServer.close();
}

View file

@ -125,27 +125,34 @@ class WebMvcSseIntegrationTests {
return Mono.just(mock(CallToolResult.class));
});
McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
//@formatter:off
var server = McpServer.async(mcpServerTransportProvider)
.serverInfo("test-server", "1.0.0")
.tools(tool)
.build();
try (
// Create client without sampling capabilities
var client = clientBuilder
.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0"))
.build()) {//@formatter:on
// Create client without sampling capabilities
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
assertThat(client.initialize()).isNotNull();
assertThat(client.initialize()).isNotNull();
try {
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
try {
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
}
}
server.close();
}
@Test
void testCreateMessageSuccess() throws InterruptedException {
// Client
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
@ -154,13 +161,6 @@ class WebMvcSseIntegrationTests {
CreateMessageResult.StopReason.STOP_SEQUENCE);
};
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build();
// Server
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
null);
@ -190,19 +190,25 @@ class WebMvcSseIntegrationTests {
return Mono.just(callResponse);
});
//@formatter:off
var mcpServer = McpServer.async(mcpServerTransportProvider)
.serverInfo("test-server", "1.0.0")
.tools(tool)
.build();
.serverInfo("test-server", "1.0.0")
.tools(tool)
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
try (
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build()) {//@formatter:on
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(response).isNotNull().isEqualTo(callResponse);
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
mcpClient.close();
assertThat(response).isNotNull().isEqualTo(callResponse);
}
mcpServer.close();
}
@ -214,41 +220,42 @@ class WebMvcSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(rootsRef.get()).isNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
}
mcpClient.close();
mcpServer.close();
}
@ -266,21 +273,22 @@ class WebMvcSseIntegrationTests {
var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> {
}).tools(tool).build();
// Create client without roots capability
// No roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
try (
// Create client without roots capability
// No roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) {
assertThat(mcpClient.initialize()).isNotNull();
assertThat(mcpClient.initialize()).isNotNull();
// Attempt to list roots should fail
try {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
// Attempt to list roots should fail
try {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
}
}
mcpClient.close();
mcpServer.close();
}
@ -292,20 +300,20 @@ class WebMvcSseIntegrationTests {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
}
mcpClient.close();
mcpServer.close();
}
@ -321,20 +329,20 @@ class WebMvcSseIntegrationTests {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
assertThat(mcpClient.initialize()).isNotNull();
assertThat(mcpClient.initialize()).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
}
mcpClient.close();
mcpServer.close();
}
@ -343,28 +351,26 @@ class WebMvcSseIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
}
// Close server while subscription is active
mcpServer.close();
// Verify client can handle server closure gracefully
mcpClient.close();
}
// ---------------------------------------
@ -400,18 +406,18 @@ class WebMvcSseIntegrationTests {
.tools(tool1)
.build();
var mcpClient = clientBuilder.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
assertThat(response).isNotNull().isEqualTo(callResponse);
assertThat(response).isNotNull().isEqualTo(callResponse);
}
mcpClient.close();
mcpServer.close();
}
@ -431,13 +437,14 @@ class WebMvcSseIntegrationTests {
return callResponse;
});
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -446,39 +453,40 @@ class WebMvcSseIntegrationTests {
.body(String.class);
assertThat(response).isNotBlank();
rootsRef.set(toolsUpdate);
}).build();
}).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(rootsRef.get()).isNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
mcpServer.notifyToolsListChanged();
mcpServer.notifyToolsListChanged();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
// Remove a tool
mcpServer.removeTool("tool1");
// Remove a tool
mcpServer.removeTool("tool1");
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
// Add a new tool
McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), (exchange, request) -> callResponse);
// Add a new tool
McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema),
(exchange, request) -> callResponse);
mcpServer.addTool(tool2);
mcpServer.addTool(tool2);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
}
mcpClient.close();
mcpServer.close();
}
@ -487,12 +495,12 @@ class WebMvcSseIntegrationTests {
var mcpServer = McpServer.sync(mcpServerTransportProvider).build();
var mcpClient = clientBuilder.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
}
mcpClient.close();
mcpServer.close();
}

View file

@ -31,6 +31,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ -453,15 +454,10 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testLoggingLevels() {
withClient(createMcpTransport(), mcpAsyncClient -> {
Mono<Void> testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> {
Mono<Void> chain = Mono.empty();
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
chain = chain.then(mcpAsyncClient.setLoggingLevel(level));
}
return chain;
}));
StepVerifier.create(testAllLevels).verifyComplete();
StepVerifier
.create(mcpAsyncClient.initialize()
.thenMany(Flux.fromArray(McpSchema.LoggingLevel.values()).flatMap(mcpAsyncClient::setLoggingLevel)))
.verifyComplete();
});
}

View file

@ -416,53 +416,4 @@ public abstract class AbstractMcpAsyncServerTests {
.doesNotThrowAnyException();
}
// ---------------------------------------
// Logging Tests
// ---------------------------------------
@Test
void testLoggingLevels() {
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
// Test all logging levels
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
var notification = McpSchema.LoggingMessageNotification.builder()
.level(level)
.logger("test-logger")
.data("Test message with level " + level)
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
}
}
@Test
void testLoggingWithoutCapability() {
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().build()) // No logging capability
.build();
var notification = McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
.logger("test-logger")
.data("Test log message")
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
}
@Test
void testLoggingWithNullNotification() {
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class);
}
}

View file

@ -388,53 +388,4 @@ public abstract class AbstractMcpSyncServerTests {
assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException();
}
// ---------------------------------------
// Logging Tests
// ---------------------------------------
@Test
void testLoggingLevels() {
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
// Test all logging levels
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
var notification = McpSchema.LoggingMessageNotification.builder()
.level(level)
.logger("test-logger")
.data("Test message with level " + level)
.build();
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
}
}
@Test
void testLoggingWithoutCapability() {
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().build()) // No logging capability
.build();
var notification = McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
.logger("test-logger")
.data("Test log message")
.build();
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
}
@Test
void testLoggingWithNullNotification() {
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class);
}
}

View file

@ -786,10 +786,9 @@ public class McpAsyncClient {
}
return this.withInitializationCheck("setting logging level", initializedResult -> {
String levelName = this.transport.unmarshalFrom(loggingLevel, new TypeReference<String>() {
});
Map<String, Object> params = Map.of("level", levelName);
return this.mcpSession.sendNotification(McpSchema.METHOD_LOGGING_SET_LEVEL, params);
var params = new McpSchema.SetLevelRequest(loggingLevel);
return this.mcpSession.sendRequest(McpSchema.METHOD_LOGGING_SET_LEVEL, params, new TypeReference<Object>() {
}).then();
});
}

View file

@ -6,7 +6,6 @@ package io.modelcontextprotocol.client;
import java.time.Duration;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;

View file

@ -21,6 +21,7 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
@ -216,11 +217,17 @@ public class McpAsyncServer {
// ---------------------------------------
/**
* Send a logging message notification to all connected clients. Messages below the
* current minimum logging level will be filtered out.
* This implementation would, incorrectly, broadcast the logging message to all
* connected clients, using a single minLoggingLevel for all of them. Similar to the
* sampling and roots, the logging level should be set per client session and use the
* ServerExchange to send the logging message to the right client.
* @param loggingMessageNotification The logging message to send
* @return A Mono that completes when the notification has been sent
* @deprecated Use
* {@link McpAsyncServerExchange#loggingNotification(LoggingMessageNotification)}
* instead.
*/
@Deprecated
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
return this.delegate.loggingNotification(loggingMessageNotification);
}
@ -257,6 +264,8 @@ public class McpAsyncServer {
private final ConcurrentHashMap<String, McpServerFeatures.AsyncPromptSpecification> prompts = new ConcurrentHashMap<>();
// FIXME: this field is deprecated and should be remvoed together with the
// broadcasting loggingNotification.
private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG;
private List<String> protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
@ -677,12 +686,22 @@ public class McpAsyncServer {
loggingMessageNotification);
}
private McpServerSession.RequestHandler<Void> setLoggerRequestHandler() {
private McpServerSession.RequestHandler<Object> setLoggerRequestHandler() {
return (exchange, params) -> {
this.minLoggingLevel = objectMapper.convertValue(params, new TypeReference<LoggingLevel>() {
});
return Mono.defer(() -> {
return Mono.empty();
SetLevelRequest newMinLoggingLevel = objectMapper.convertValue(params,
new TypeReference<SetLevelRequest>() {
});
exchange.setMinLoggingLevel(newMinLoggingLevel.level());
// FIXME: this field is deprecated and should be removed together
// with the broadcasting loggingNotification.
this.minLoggingLevel = newMinLoggingLevel.level();
return Mono.just(Map.of());
});
};
}

View file

@ -1,9 +1,16 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
/**
@ -11,6 +18,7 @@ import reactor.core.publisher.Mono;
* exchange provides methods to interact with the client and query its capabilities.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
*/
public class McpAsyncServerExchange {
@ -20,6 +28,8 @@ public class McpAsyncServerExchange {
private final McpSchema.Implementation clientInfo;
private volatile LoggingLevel minLoggingLevel = LoggingLevel.INFO;
private static final TypeReference<McpSchema.CreateMessageResult> CREATE_MESSAGE_RESULT_TYPE_REF = new TypeReference<>() {
};
@ -101,4 +111,38 @@ public class McpAsyncServerExchange {
LIST_ROOTS_RESULT_TYPE_REF);
}
/**
* Send a logging message notification to all connected clients. Messages below the
* current minimum logging level will be filtered out.
* @param loggingMessageNotification The logging message to send
* @return A Mono that completes when the notification has been sent
*/
public Mono<Void> loggingNotification(LoggingMessageNotification loggingMessageNotification) {
if (loggingMessageNotification == null) {
return Mono.error(new McpError("Logging message must not be null"));
}
return Mono.defer(() -> {
if (this.isNotificationForLevelAllowed(loggingMessageNotification.level())) {
return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_MESSAGE, loggingMessageNotification);
}
return Mono.empty();
});
}
/**
* Set the minimum logging level for the client. Messages below this level will be
* filtered out.
* @param minLoggingLevel The minimum logging level
*/
void setMinLoggingLevel(LoggingLevel minLoggingLevel) {
Assert.notNull(minLoggingLevel, "minLoggingLevel must not be null");
this.minLoggingLevel = minLoggingLevel;
}
private boolean isNotificationForLevelAllowed(LoggingLevel loggingLevel) {
return loggingLevel.level() >= this.minLoggingLevel.level();
}
}

View file

@ -4,9 +4,7 @@
package io.modelcontextprotocol.server;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.util.Assert;
@ -151,9 +149,16 @@ public class McpSyncServer {
}
/**
* Send a logging message notification to all clients.
* @param loggingMessageNotification The logging message notification to send
* This implementation would, incorrectly, broadcast the logging message to all
* connected clients, using a single minLoggingLevel for all of them. Similar to the
* sampling and roots, the logging level should be set per client session and use the
* ServerExchange to send the logging message to the right client.
* @param loggingMessageNotification The logging message to send
* @deprecated Use
* {@link McpSyncServerExchange#loggingNotification(LoggingMessageNotification)}
* instead.
*/
@Deprecated
public void loggingNotification(LoggingMessageNotification loggingMessageNotification) {
this.asyncServer.loggingNotification(loggingMessageNotification).block();
}

View file

@ -1,13 +1,19 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package io.modelcontextprotocol.server;
import com.fasterxml.jackson.core.type.TypeReference;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
/**
* Represents a synchronous exchange with a Model Context Protocol (MCP) client. The
* exchange provides methods to interact with the client and query its capabilities.
*
* @author Dariusz Jędrzejczyk
* @author Christian Tzolov
*/
public class McpSyncServerExchange {
@ -75,4 +81,13 @@ public class McpSyncServerExchange {
return this.exchange.listRoots(cursor).block();
}
/**
* Send a logging message notification to all connected clients. Messages below the
* current minimum logging level will be filtered out.
* @param loggingMessageNotification The logging message to send
*/
public void loggingNotification(LoggingMessageNotification loggingMessageNotification) {
this.exchange.loggingNotification(loggingMessageNotification).block();
}
}

View file

@ -1165,6 +1165,11 @@ public final class McpSchema {
} // @formatter:on
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) {
}
// ---------------------------
// Autocomplete
// ---------------------------

View file

@ -31,6 +31,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ -454,15 +455,10 @@ public abstract class AbstractMcpAsyncClientTests {
@Test
void testLoggingLevels() {
withClient(createMcpTransport(), mcpAsyncClient -> {
Mono<Void> testAllLevels = mcpAsyncClient.initialize().then(Mono.defer(() -> {
Mono<Void> chain = Mono.empty();
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
chain = chain.then(mcpAsyncClient.setLoggingLevel(level));
}
return chain;
}));
StepVerifier.create(testAllLevels).verifyComplete();
StepVerifier
.create(mcpAsyncClient.initialize()
.thenMany(Flux.fromArray(McpSchema.LoggingLevel.values()).flatMap(mcpAsyncClient::setLoggingLevel)))
.verifyComplete();
});
}

View file

@ -415,53 +415,4 @@ public abstract class AbstractMcpAsyncServerTests {
.doesNotThrowAnyException();
}
// ---------------------------------------
// Logging Tests
// ---------------------------------------
@Test
void testLoggingLevels() {
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
// Test all logging levels
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
var notification = McpSchema.LoggingMessageNotification.builder()
.level(level)
.logger("test-logger")
.data("Test message with level " + level)
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
}
}
@Test
void testLoggingWithoutCapability() {
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().build()) // No logging capability
.build();
var notification = McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
.logger("test-logger")
.data("Test log message")
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(notification)).verifyComplete();
}
@Test
void testLoggingWithNullNotification() {
var mcpAsyncServer = McpServer.async(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
StepVerifier.create(mcpAsyncServer.loggingNotification(null)).verifyError(McpError.class);
}
}

View file

@ -387,53 +387,4 @@ public abstract class AbstractMcpSyncServerTests {
assertThatCode(() -> noConsumersServer.closeGracefully()).doesNotThrowAnyException();
}
// ---------------------------------------
// Logging Tests
// ---------------------------------------
@Test
void testLoggingLevels() {
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
// Test all logging levels
for (McpSchema.LoggingLevel level : McpSchema.LoggingLevel.values()) {
var notification = McpSchema.LoggingMessageNotification.builder()
.level(level)
.logger("test-logger")
.data("Test message with level " + level)
.build();
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
}
}
@Test
void testLoggingWithoutCapability() {
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().build()) // No logging capability
.build();
var notification = McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
.logger("test-logger")
.data("Test log message")
.build();
assertThatCode(() -> mcpSyncServer.loggingNotification(notification)).doesNotThrowAnyException();
}
@Test
void testLoggingWithNullNotification() {
var mcpSyncServer = McpServer.sync(createMcpTransportProvider())
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().build())
.build();
assertThatThrownBy(() -> mcpSyncServer.loggingNotification(null)).isInstanceOf(McpError.class);
}
}

View file

@ -8,7 +8,6 @@ import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.spec.McpSchema;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.startup.Tomcat;
@ -78,9 +77,13 @@ public class HttpServletSseServerCustomContextPathTests {
@Test
void testCustomContextPath() {
McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").build();
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
assertThat(client.initialize()).isNotNull();
var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").build();
try (//@formatter:off
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) .build()) { //@formatter:on
assertThat(client.initialize()).isNotNull();
}
server.close();
}
}

View file

@ -4,6 +4,7 @@
package io.modelcontextprotocol.server.transport;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@ -44,7 +45,7 @@ import static org.mockito.Mockito.mock;
public class HttpServletSseServerTransportProviderIntegrationTests {
private static final int PORT = 8185;
private static final int PORT = 8189;
private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
@ -110,27 +111,29 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
return Mono.just(mock(CallToolResult.class));
});
McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
// Create client without sampling capabilities
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")).build();
try (
// Create client without sampling capabilities
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0"))
.build()) {
assertThat(client.initialize()).isNotNull();
assertThat(client.initialize()).isNotNull();
try {
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
try {
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class)
.hasMessage("Client must be configured with sampling capabilities");
}
}
server.close();
}
@Test
void testCreateMessageSuccess() throws InterruptedException {
// Client
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
assertThat(request.messages()).hasSize(1);
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
@ -139,13 +142,6 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
CreateMessageResult.StopReason.STOP_SEQUENCE);
};
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build();
// Server
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
null);
@ -180,15 +176,19 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
.tools(tool)
.build();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
.capabilities(ClientCapabilities.builder().sampling().build())
.sampling(samplingHandler)
.build()) {
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
mcpClient.close();
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
}
mcpServer.close();
}
@ -200,42 +200,43 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(rootsRef.get()).isNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
// Remove a root
mcpClient.removeRoot(roots.get(0).uri());
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
});
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
// Add a new root
var root3 = new Root("uri3://", "root3");
mcpClient.addRoot(root3);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
});
mcpClient.close();
mcpServer.close();
mcpServer.close();
}
}
@Test
@ -252,21 +253,19 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
var mcpServer = McpServer.sync(mcpServerTransportProvider).rootsChangeHandler((exchange, rootsUpdate) -> {
}).tools(tool).build();
// Create client without roots capability
// No roots capability
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build();
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) {
assertThat(mcpClient.initialize()).isNotNull();
assertThat(mcpClient.initialize()).isNotNull();
// Attempt to list roots should fail
try {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
// Attempt to list roots should fail
try {
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
}
catch (McpError e) {
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
}
}
mcpClient.close();
mcpServer.close();
}
@ -278,20 +277,20 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(List.of()) // Empty roots list
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
}
mcpClient.close();
mcpServer.close();
}
@ -307,20 +306,20 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
assertThat(mcpClient.initialize()).isNotNull();
assertThat(mcpClient.initialize()).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef1.get()).containsAll(roots);
assertThat(rootsRef2.get()).containsAll(roots);
});
}
mcpClient.close();
mcpServer.close();
}
@ -329,28 +328,26 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
List<Root> roots = List.of(new Root("uri1://", "root1"));
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
.build();
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
.roots(roots)
.build();
.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
mcpClient.rootsListChangedNotification();
mcpClient.rootsListChangedNotification();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(roots);
});
}
// Close server while subscription is active
mcpServer.close();
// Verify client can handle server closure gracefully
mcpClient.close();
}
// ---------------------------------------
@ -386,19 +383,18 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
.tools(tool1)
.build();
var mcpClient = clientBuilder.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
}
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
mcpClient.close();
mcpServer.close();
}
@ -418,13 +414,14 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
return callResponse;
});
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().tools(true).build())
.tools(tool1)
.build();
AtomicReference<List<Tool>> rootsRef = new AtomicReference<>();
var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
// perform a blocking call to a remote service
String response = RestClient.create()
.get()
@ -433,53 +430,167 @@ public class HttpServletSseServerTransportProviderIntegrationTests {
.body(String.class);
assertThat(response).isNotBlank();
rootsRef.set(toolsUpdate);
}).build();
}).build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
assertThat(rootsRef.get()).isNull();
assertThat(rootsRef.get()).isNull();
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
mcpServer.notifyToolsListChanged();
mcpServer.notifyToolsListChanged();
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool1.tool()));
});
// Remove a tool
mcpServer.removeTool("tool1");
// Remove a tool
mcpServer.removeTool("tool1");
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).isEmpty();
});
// Add a new tool
McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema), (exchange, request) -> callResponse);
// Add a new tool
McpServerFeatures.SyncToolSpecification tool2 = new McpServerFeatures.SyncToolSpecification(
new McpSchema.Tool("tool2", "tool2 description", emptyJsonSchema),
(exchange, request) -> callResponse);
mcpServer.addTool(tool2);
mcpServer.addTool(tool2);
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
assertThat(rootsRef.get()).containsAll(List.of(tool2.tool()));
});
}
mcpClient.close();
mcpServer.close();
}
@Test
void testInitialize() {
var mcpServer = McpServer.sync(mcpServerTransportProvider).build();
var mcpClient = clientBuilder.build();
try (var mcpClient = clientBuilder.build()) {
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
}
mcpClient.close();
mcpServer.close();
}
// ---------------------------------------
// Logging Tests
// ---------------------------------------
@Test
void testLoggingNotification() {
// Create a list to store received logging notifications
List<McpSchema.LoggingMessageNotification> receivedNotifications = new ArrayList<>();
// Create server with a tool that sends logging notifications
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
new McpSchema.Tool("logging-test", "Test logging notifications", emptyJsonSchema),
(exchange, request) -> {
// Create and send notifications with different levels
// This should be filtered out (DEBUG < NOTICE)
exchange
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.DEBUG)
.logger("test-logger")
.data("Debug message")
.build())
.block();
// This should be sent (NOTICE >= NOTICE)
exchange
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.NOTICE)
.logger("test-logger")
.data("Notice message")
.build())
.block();
// This should be sent (ERROR > NOTICE)
exchange
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.ERROR)
.logger("test-logger")
.data("Error message")
.build())
.block();
// This should be filtered out (INFO < NOTICE)
exchange
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.INFO)
.logger("test-logger")
.data("Another info message")
.build())
.block();
// This should be sent (ERROR >= NOTICE)
exchange
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
.level(McpSchema.LoggingLevel.ERROR)
.logger("test-logger")
.data("Another error message")
.build())
.block();
return Mono.just(new CallToolResult("Logging test completed", false));
});
var mcpServer = McpServer.async(mcpServerTransportProvider)
.serverInfo("test-server", "1.0.0")
.capabilities(ServerCapabilities.builder().logging().tools(true).build())
.tools(tool)
.build();
try (
// Create client with logging notification handler
var mcpClient = clientBuilder.loggingConsumer(notification -> {
receivedNotifications.add(notification);
}).build()) {
// Initialize client
InitializeResult initResult = mcpClient.initialize();
assertThat(initResult).isNotNull();
// Set minimum logging level to NOTICE
mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE);
// Call the tool that sends logging notifications
CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of()));
assertThat(result).isNotNull();
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed");
// Wait for notifications to be processed
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
System.out.println("Received notifications: " + receivedNotifications);
// Should have received 3 notifications (1 NOTICE and 2 ERROR)
assertThat(receivedNotifications).hasSize(3);
// First notification should be NOTICE level
assertThat(receivedNotifications.get(0).level()).isEqualTo(McpSchema.LoggingLevel.NOTICE);
assertThat(receivedNotifications.get(0).logger()).isEqualTo("test-logger");
assertThat(receivedNotifications.get(0).data()).isEqualTo("Notice message");
// Second notification should be ERROR level
assertThat(receivedNotifications.get(1).level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
assertThat(receivedNotifications.get(1).logger()).isEqualTo("test-logger");
assertThat(receivedNotifications.get(1).data()).isEqualTo("Error message");
// Third notification should be ERROR level
assertThat(receivedNotifications.get(2).level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
assertThat(receivedNotifications.get(2).logger()).isEqualTo("test-logger");
assertThat(receivedNotifications.get(2).data()).isEqualTo("Another error message");
});
}
mcpServer.close();
}