refactor: consolidate content annotations into Annotations (#242)

Updates all Content types to represent annotations as nested objects
instead of directly on the content itself.

- Refactor TextContent, ImageContent, and EmbeddedResource to use Annotations object instead of separate audience/priority fields
- Add backward-compatible deprecated methods for audience() and priority() accessors
- Add parameterized tests for message annotations across different message types (success, error, debug)
- Test both text and image content annotations with proper priority and audience validation

This brings the implementation in line with the content types as modeled by the specification:
c87a0da6d8/schema/2025-03-26/schema.json (L1970-L1973)
This commit is contained in:
Luca Chang 2025-05-16 15:49:43 -07:00 • committed by Christian Tzolov
commit fb40047c39
6 changed files with 321 additions and 10 deletions

View file

@ -54,6 +54,11 @@
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>

View file

@ -34,6 +34,8 @@ 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 org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ -41,6 +43,7 @@ import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/**
@ -208,6 +211,64 @@ public abstract class AbstractMcpAsyncClientTests {
});
}
@ParameterizedTest
@ValueSource(strings = { "success", "error", "debug" })
void testCallToolWithMessageAnnotations(String messageType) {
McpClientTransport transport = createMcpTransport();
withClient(transport, mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()
.then(mcpAsyncClient.callTool(new McpSchema.CallToolRequest("annotatedMessage",
Map.of("messageType", messageType, "includeImage", true)))))
.consumeNextWith(result -> {
assertThat(result).isNotNull();
assertThat(result.isError()).isNotEqualTo(true);
assertThat(result.content()).isNotEmpty();
assertThat(result.content()).allSatisfy(content -> {
switch (content.type()) {
case "text":
McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class,
content);
assertThat(textContent.text()).isNotEmpty();
assertThat(textContent.annotations()).isNotNull();
switch (messageType) {
case "error":
assertThat(textContent.annotations().priority()).isEqualTo(1.0);
assertThat(textContent.annotations().audience())
.containsOnly(McpSchema.Role.USER, McpSchema.Role.ASSISTANT);
break;
case "success":
assertThat(textContent.annotations().priority()).isEqualTo(0.7);
assertThat(textContent.annotations().audience())
.containsExactly(McpSchema.Role.USER);
break;
case "debug":
assertThat(textContent.annotations().priority()).isEqualTo(0.3);
assertThat(textContent.annotations().audience())
.containsExactly(McpSchema.Role.ASSISTANT);
break;
default:
throw new IllegalStateException("Unexpected value: " + content.type());
}
break;
case "image":
McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class,
content);
assertThat(imageContent.data()).isNotEmpty();
assertThat(imageContent.annotations()).isNotNull();
assertThat(imageContent.annotations().priority()).isEqualTo(0.5);
assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER);
break;
default:
fail("Unexpected content type: " + content.type());
}
});
})
.verifyComplete();
});
}
@Test
void testListResourcesWithoutInitialization() {
verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(null), "listing resources");

View file

@ -31,12 +31,15 @@ import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/**
@ -183,6 +186,60 @@ public abstract class AbstractMcpSyncClientTests {
});
}
@ParameterizedTest
@ValueSource(strings = { "success", "error", "debug" })
void testCallToolWithMessageAnnotations(String messageType) {
McpClientTransport transport = createMcpTransport();
withClient(transport, client -> {
client.initialize();
McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("annotatedMessage",
Map.of("messageType", messageType, "includeImage", true)));
assertThat(result).isNotNull();
assertThat(result.isError()).isNotEqualTo(true);
assertThat(result.content()).isNotEmpty();
assertThat(result.content()).allSatisfy(content -> {
switch (content.type()) {
case "text":
McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class, content);
assertThat(textContent.text()).isNotEmpty();
assertThat(textContent.annotations()).isNotNull();
switch (messageType) {
case "error":
assertThat(textContent.annotations().priority()).isEqualTo(1.0);
assertThat(textContent.annotations().audience()).containsOnly(McpSchema.Role.USER,
McpSchema.Role.ASSISTANT);
break;
case "success":
assertThat(textContent.annotations().priority()).isEqualTo(0.7);
assertThat(textContent.annotations().audience()).containsExactly(McpSchema.Role.USER);
break;
case "debug":
assertThat(textContent.annotations().priority()).isEqualTo(0.3);
assertThat(textContent.annotations().audience())
.containsExactly(McpSchema.Role.ASSISTANT);
break;
default:
throw new IllegalStateException("Unexpected value: " + content.type());
}
break;
case "image":
McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class, content);
assertThat(imageContent.data()).isNotEmpty();
assertThat(imageContent.annotations()).isNotNull();
assertThat(imageContent.annotations().priority()).isEqualTo(0.5);
assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER);
break;
default:
fail("Unexpected content type: " + content.type());
}
});
});
}
@Test
void testPingWithoutInitialization() {
verifyCallSucceedsWithImplicitInitialization(client -> client.ping(), "pinging the server");

View file

@ -1384,22 +1384,68 @@ public final class McpSchema {
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record TextContent( // @formatter:off
@JsonProperty("audience") List<Role> audience,
@JsonProperty("priority") Double priority,
@JsonProperty("text") String text) implements Content { // @formatter:on
@JsonProperty("annotations") Annotations annotations,
@JsonProperty("text") String text) implements Annotated, Content { // @formatter:on
public TextContent(String content) {
this(null, null, content);
this(null, content);
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link TextContent#TextContent(Annotations, String)} instead.
*/
public TextContent(List<Role> audience, Double priority, String content) {
this(audience != null || priority != null ? new Annotations(audience, priority) : null, content);
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link TextContent#annotations()} instead.
*/
public List<Role> audience() {
return annotations == null ? null : annotations.audience();
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link TextContent#annotations()} instead.
*/
public Double priority() {
return annotations == null ? null : annotations.priority();
}
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ImageContent( // @formatter:off
@JsonProperty("audience") List<Role> audience,
@JsonProperty("priority") Double priority,
@JsonProperty("annotations") Annotations annotations,
@JsonProperty("data") String data,
@JsonProperty("mimeType") String mimeType) implements Content { // @formatter:on
@JsonProperty("mimeType") String mimeType) implements Annotated, Content { // @formatter:on
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link ImageContent#ImageContent(Annotations, String, String)} instead.
*/
public ImageContent(List<Role> audience, Double priority, String data, String mimeType) {
this(audience != null || priority != null ? new Annotations(audience, priority) : null, data, mimeType);
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link ImageContent#annotations()} instead.
*/
public List<Role> audience() {
return annotations == null ? null : annotations.audience();
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link ImageContent#annotations()} instead.
*/
public Double priority() {
return annotations == null ? null : annotations.priority();
}
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@ -1413,9 +1459,33 @@ public final class McpSchema {
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record EmbeddedResource( // @formatter:off
@JsonProperty("audience") List<Role> audience,
@JsonProperty("priority") Double priority,
@JsonProperty("resource") ResourceContents resource) implements Content { // @formatter:on
@JsonProperty("annotations") Annotations annotations,
@JsonProperty("resource") ResourceContents resource) implements Annotated, Content { // @formatter:on
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link EmbeddedResource#EmbeddedResource(Annotations, ResourceContents)}
* instead.
*/
public EmbeddedResource(List<Role> audience, Double priority, ResourceContents resource) {
this(audience != null || priority != null ? new Annotations(audience, priority) : null, resource);
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link EmbeddedResource#annotations()} instead.
*/
public List<Role> audience() {
return annotations == null ? null : annotations.audience();
}
/**
* @deprecated Only exists for backwards-compatibility purposes. Use
* {@link EmbeddedResource#annotations()} instead.
*/
public Double priority() {
return annotations == null ? null : annotations.priority();
}
}
// ---------------------------

View file

@ -34,6 +34,8 @@ 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 org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ -41,6 +43,7 @@ import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/**
@ -209,6 +212,64 @@ public abstract class AbstractMcpAsyncClientTests {
});
}
@ParameterizedTest
@ValueSource(strings = { "success", "error", "debug" })
void testCallToolWithMessageAnnotations(String messageType) {
McpClientTransport transport = createMcpTransport();
withClient(transport, mcpAsyncClient -> {
StepVerifier.create(mcpAsyncClient.initialize()
.then(mcpAsyncClient.callTool(new McpSchema.CallToolRequest("annotatedMessage",
Map.of("messageType", messageType, "includeImage", true)))))
.consumeNextWith(result -> {
assertThat(result).isNotNull();
assertThat(result.isError()).isNotEqualTo(true);
assertThat(result.content()).isNotEmpty();
assertThat(result.content()).allSatisfy(content -> {
switch (content.type()) {
case "text":
McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class,
content);
assertThat(textContent.text()).isNotEmpty();
assertThat(textContent.annotations()).isNotNull();
switch (messageType) {
case "error":
assertThat(textContent.annotations().priority()).isEqualTo(1.0);
assertThat(textContent.annotations().audience())
.containsOnly(McpSchema.Role.USER, McpSchema.Role.ASSISTANT);
break;
case "success":
assertThat(textContent.annotations().priority()).isEqualTo(0.7);
assertThat(textContent.annotations().audience())
.containsExactly(McpSchema.Role.USER);
break;
case "debug":
assertThat(textContent.annotations().priority()).isEqualTo(0.3);
assertThat(textContent.annotations().audience())
.containsExactly(McpSchema.Role.ASSISTANT);
break;
default:
throw new IllegalStateException("Unexpected value: " + content.type());
}
break;
case "image":
McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class,
content);
assertThat(imageContent.data()).isNotEmpty();
assertThat(imageContent.annotations()).isNotNull();
assertThat(imageContent.annotations().priority()).isEqualTo(0.5);
assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER);
break;
default:
fail("Unexpected content type: " + content.type());
}
});
})
.verifyComplete();
});
}
@Test
void testListResourcesWithoutInitialization() {
verifyCallSucceedsWithImplicitInitialization(client -> client.listResources(null), "listing resources");

View file

@ -31,6 +31,8 @@ import io.modelcontextprotocol.spec.McpSchema.UnsubscribeRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
@ -39,6 +41,7 @@ import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/**
@ -229,6 +232,60 @@ public abstract class AbstractMcpSyncClientTests {
});
}
@ParameterizedTest
@ValueSource(strings = { "success", "error", "debug" })
void testCallToolWithMessageAnnotations(String messageType) {
McpClientTransport transport = createMcpTransport();
withClient(transport, client -> {
client.initialize();
McpSchema.CallToolResult result = client.callTool(new McpSchema.CallToolRequest("annotatedMessage",
Map.of("messageType", messageType, "includeImage", true)));
assertThat(result).isNotNull();
assertThat(result.isError()).isNotEqualTo(true);
assertThat(result.content()).isNotEmpty();
assertThat(result.content()).allSatisfy(content -> {
switch (content.type()) {
case "text":
McpSchema.TextContent textContent = assertInstanceOf(McpSchema.TextContent.class, content);
assertThat(textContent.text()).isNotEmpty();
assertThat(textContent.annotations()).isNotNull();
switch (messageType) {
case "error":
assertThat(textContent.annotations().priority()).isEqualTo(1.0);
assertThat(textContent.annotations().audience()).containsOnly(McpSchema.Role.USER,
McpSchema.Role.ASSISTANT);
break;
case "success":
assertThat(textContent.annotations().priority()).isEqualTo(0.7);
assertThat(textContent.annotations().audience()).containsExactly(McpSchema.Role.USER);
break;
case "debug":
assertThat(textContent.annotations().priority()).isEqualTo(0.3);
assertThat(textContent.annotations().audience())
.containsExactly(McpSchema.Role.ASSISTANT);
break;
default:
throw new IllegalStateException("Unexpected value: " + content.type());
}
break;
case "image":
McpSchema.ImageContent imageContent = assertInstanceOf(McpSchema.ImageContent.class, content);
assertThat(imageContent.data()).isNotEmpty();
assertThat(imageContent.annotations()).isNotNull();
assertThat(imageContent.annotations().priority()).isEqualTo(0.5);
assertThat(imageContent.annotations().audience()).containsExactly(McpSchema.Role.USER);
break;
default:
fail("Unexpected content type: " + content.type());
}
});
});
}
@Test
void testRootsListChangedWithoutInitialization() {
verifyNotificationSucceedsWithImplicitInitialization(client -> client.rootsListChangedNotification(),