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:
parent
b5ece766e7
commit
fb40047c39
6 changed files with 321 additions and 10 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue