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..bd513d79e 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
@@ -18,6 +18,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.util.Assert;
@@ -5355,7 +5356,8 @@ 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 Logging data as text. The wire format may carry any JSON value; on
+ * deserialization non-string values are coerced to their JSON text form.
* @param meta See specification for notes on _meta usage
*
* Note: {@code level} and {@code data} are required by the MCP specification.
@@ -5375,24 +5377,46 @@ public record LoggingMessageNotification( // @formatter:off
Assert.notNull(data, "data must not be null");
}
+ /**
+ * Coerce notification {@code data} to a string while keeping the public API as
+ * {@link String}. The MCP spec allows any JSON value; servers such as FastMCP
+ * send objects (for example {@code {"msg":"...","extra":null}}). Non-string
+ * values are serialized to JSON text so deserialization does not fail.
+ */
+ private static String coerceDataToString(Object data) {
+ if (data == null) {
+ return null;
+ }
+ if (data instanceof String stringData) {
+ return stringData;
+ }
+ try {
+ return McpJsonDefaults.getMapper().writeValueAsString(data);
+ }
+ catch (IOException ex) {
+ throw new IllegalArgumentException("Failed to serialize logging notification data to JSON text", ex);
+ }
+ }
+
@JsonCreator
static LoggingMessageNotification fromJson(@JsonProperty("level") LoggingLevel level,
- @JsonProperty("logger") String loggerName, @JsonProperty("data") String data,
+ @JsonProperty("logger") String loggerName, @JsonProperty("data") Object data,
@JsonProperty("_meta") Map meta) {
- if (level == null || data == null) {
+ String dataText = coerceDataToString(data);
+ if (level == null || dataText == null) {
List missing = new ArrayList<>();
if (level == null) {
missing.add("level -> INFO");
level = LoggingLevel.INFO;
}
- if (data == null) {
+ if (dataText == null) {
missing.add("data -> ''");
- data = "";
+ dataText = "";
}
McpSchema.logger.warn("LoggingMessageNotification: missing required fields during deserialization: {}",
String.join(", ", missing));
}
- return new LoggingMessageNotification(level, loggerName, data, meta);
+ return new LoggingMessageNotification(level, loggerName, dataText, meta);
}
// backwards compatibility constructor
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..ed4912add 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java
@@ -2729,6 +2729,59 @@ void testLoggingMessageNotificationDeserializationWithMissingRequiredFields() th
assertThat(notification.data()).isEmpty();
}
+ @Test
+ void testLoggingMessageNotificationDeserializationWithObjectData() throws Exception {
+ // FastMCP ctx.info()/ctx.log() sends data as an object; the MCP spec allows any
+ // JSON
+ // value. Keep the public API as String by coercing non-string data to JSON text.
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue("""
+ {"level":"info","logger":"server","data":{"msg":"working","extra":null}}""",
+ McpSchema.LoggingMessageNotification.class);
+
+ assertThat(notification).isNotNull();
+ assertThat(notification.level()).isEqualTo(McpSchema.LoggingLevel.INFO);
+ assertThat(notification.logger()).isEqualTo("server");
+ assertThatJson(notification.data()).isObject().containsEntry("msg", "working").containsEntry("extra", null);
+ }
+
+ @Test
+ void testLoggingMessageNotificationDeserializationWithStringDataStillWorks() throws Exception {
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue("""
+ {"level":"warning","logger":"my-logger","data":"plain text"}""",
+ McpSchema.LoggingMessageNotification.class);
+
+ assertThat(notification.data()).isEqualTo("plain text");
+ assertThat(notification.level()).isEqualTo(McpSchema.LoggingLevel.WARNING);
+ }
+
+ @Test
+ void testLoggingMessageNotificationDeserializationWithArrayAndNumberData() throws Exception {
+ McpSchema.LoggingMessageNotification arrayNotification = JSON_MAPPER.readValue("""
+ {"level":"debug","data":[1,"two",true]}""", McpSchema.LoggingMessageNotification.class);
+ assertThatJson(arrayNotification.data()).isArray().containsExactly(1, "two", true);
+
+ McpSchema.LoggingMessageNotification numberNotification = JSON_MAPPER.readValue("""
+ {"level":"debug","data":42}""", McpSchema.LoggingMessageNotification.class);
+ assertThat(numberNotification.data()).isEqualTo("42");
+
+ McpSchema.LoggingMessageNotification boolNotification = JSON_MAPPER.readValue("""
+ {"level":"debug","data":true}""", McpSchema.LoggingMessageNotification.class);
+ assertThat(boolNotification.data()).isEqualTo("true");
+ }
+
+ @Test
+ void testLoggingMessageNotificationConvertValueWithObjectData() {
+ Map params = new HashMap<>();
+ params.put("level", "info");
+ params.put("logger", "server");
+ params.put("data", Map.of("msg", "working"));
+
+ McpSchema.LoggingMessageNotification notification = JSON_MAPPER.convertValue(params,
+ McpSchema.LoggingMessageNotification.class);
+
+ assertThatJson(notification.data()).isObject().containsEntry("msg", "working");
+ }
+
// --- Icon tests (SEP-973) ---
@Test