Skip to content
Open
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
36 changes: 30 additions & 6 deletions mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
* <p>
* Note: {@code level} and {@code data} are required by the MCP specification.
Expand All @@ -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<String, Object> meta) {
if (level == null || data == null) {
String dataText = coerceDataToString(data);
if (level == null || dataText == null) {
List<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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
Expand Down