diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
index b088396e4..fa467b213 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
@@ -5355,7 +5355,10 @@ static ResourcesUpdatedNotification fromJson(@JsonProperty("uri") String uri,
*
* @param level The severity levels. The minimum log level is set by the client.
* @param logger The logger that generated the message.
- * @param data JSON-serializable logging data.
+ * @param data The logging data, as a string. The MCP specification allows any JSON
+ * value; when a non-string value is received, it is converted with
+ * {@link Object#toString()}. The resulting format is intended for human readers and
+ * is not guaranteed to be valid JSON.
* @param meta See specification for notes on _meta usage
*
* Note: {@code level} and {@code data} are required by the MCP specification.
@@ -5377,8 +5380,9 @@ public record LoggingMessageNotification( // @formatter:off
@JsonCreator
static LoggingMessageNotification fromJson(@JsonProperty("level") LoggingLevel level,
- @JsonProperty("logger") String loggerName, @JsonProperty("data") String data,
+ @JsonProperty("logger") String loggerName, @JsonProperty("data") Object rawData,
@JsonProperty("_meta") Map meta) {
+ String data = rawData != null ? rawData.toString() : null;
if (level == null || data == null) {
List missing = new ArrayList<>();
if (level == null) {
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java
index 2f01bb06e..d89171b9f 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java
@@ -559,4 +559,31 @@ void testPingMessageRequestHandling() {
asyncMcpClient.closeGracefully();
}
+ @Test
+ void testLoggingNotificationWithObjectData() throws IOException {
+ MockMcpClientTransport transport = initializationEnabledTransport();
+
+ List receivedNotifications = new ArrayList<>();
+
+ McpAsyncClient asyncMcpClient = McpClient.async(transport)
+ .loggingConsumer(notification -> Mono.fromRunnable(() -> receivedNotifications.add(notification)))
+ .build();
+
+ assertThat(asyncMcpClient.initialize().block()).isNotNull();
+
+ // Logging notification as sent by FastMCP servers, with an object as data
+ McpSchema.JSONRPCMessage notification = McpSchema.deserializeJsonRpcMessage(JSON_MAPPER, """
+ {"jsonrpc":"2.0","method":"notifications/message",
+ "params":{"level":"info","logger":"server","data":{"msg":"working","extra":null}}}""");
+ transport.simulateIncomingMessage(notification);
+
+ assertThat(receivedNotifications).singleElement().satisfies(received -> {
+ assertThat(received.level()).isEqualTo(McpSchema.LoggingLevel.INFO);
+ assertThat(received.logger()).isEqualTo("server");
+ assertThat(received.data()).isEqualTo("{msg=working, extra=null}");
+ });
+
+ asyncMcpClient.closeGracefully();
+ }
+
}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
index ab9bc8643..a9cddf41b 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
@@ -17,6 +17,7 @@
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
@@ -2729,6 +2730,60 @@ void testLoggingMessageNotificationDeserializationWithMissingRequiredFields() th
assertThat(notification.data()).isEmpty();
}
+ @Test
+ void testLoggingMessageNotificationDeserializationWithNullData() throws Exception {
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue("""
+ {"level":"info","data":null}""", McpSchema.LoggingMessageNotification.class);
+
+ assertThat(notification.data()).isEmpty();
+ }
+
+ @Test
+ void testLoggingMessageNotificationDeserializationWithStringData() throws Exception {
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue("""
+ {"level":"warning","logger":"my-logger","data":"plain text"}""",
+ McpSchema.LoggingMessageNotification.class);
+
+ assertThat(notification.level()).isEqualTo(McpSchema.LoggingLevel.WARNING);
+ assertThat(notification.logger()).isEqualTo("my-logger");
+ assertThat(notification.data()).isEqualTo("plain text");
+ }
+
+ @Test
+ void testLoggingMessageNotificationDeserializationWithObjectData() throws Exception {
+ // FastMCP sends {"msg": ..., "extra": ...} for every ctx.log() call
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue("""
+ {
+ "level": "info",
+ "logger": "server",
+ "data": {
+ "msg": "working",
+ "extra": {
+ "user": "bob",
+ "ids": [1, 2 ]
+ }
+ }
+ }""", McpSchema.LoggingMessageNotification.class);
+
+ assertThat(notification.level()).isEqualTo(McpSchema.LoggingLevel.INFO);
+ assertThat(notification.logger()).isEqualTo("server");
+ assertThat(notification.data()).isEqualTo("{msg=working, extra={user=bob, ids=[1, 2]}}");
+ }
+
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', textBlock = """
+ [1,"two",true] | [1, two, true]
+ 42 | 42
+ 1.5 | 1.5
+ true | true
+ """)
+ void testLoggingMessageNotificationDeserializationWithNonStringData(String data, String expected) throws Exception {
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue("""
+ {"level":"debug","data":%s}""".formatted(data), McpSchema.LoggingMessageNotification.class);
+
+ assertThat(notification.data()).isEqualTo(expected);
+ }
+
// --- Icon tests (SEP-973) ---
@Test