diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java index 47f5452950..b7bd906a0f 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java @@ -16,12 +16,11 @@ package org.springframework.ai.mcp.annotation.adapter; -import java.util.List; - import io.modelcontextprotocol.spec.McpSchema; import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.mcp.annotation.common.MetaUtils; +import org.springframework.ai.mcp.annotation.common.ResourceAnnotationsUtils; /** * Utility class that converts {@link McpResource} annotations into MCP schema objects. @@ -45,27 +44,15 @@ public static McpSchema.Resource asResource(McpResource mcpResourceAnnotation) { name = "resource"; // Default name when not specified } var meta = MetaUtils.getMeta(mcpResourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(mcpResourceAnnotation.annotations()); - var resourceBuilder = McpSchema.Resource.builder(mcpResourceAnnotation.uri(), name) + return McpSchema.Resource.builder(mcpResourceAnnotation.uri(), name) .title(mcpResourceAnnotation.title()) .description(mcpResourceAnnotation.description()) .mimeType(mcpResourceAnnotation.mimeType()) - .meta(meta); - - // Only set annotations if not default value is provided - // This is a workaround since Java annotations do not support null default values - // and we want to avoid setting empty annotations. - // The default annotations value is ignored. - // The user must explicitly set the annotations to get them included. - var annotations = mcpResourceAnnotation.annotations(); - if (annotations != null && annotations.lastModified() != null && !annotations.lastModified().isEmpty()) { - resourceBuilder.annotations(McpSchema.Annotations.builder() - .audience(List.of(annotations.audience())) - .priority(annotations.priority()) - .build()); - } - - return resourceBuilder.build(); + .annotations(annotations) + .meta(meta) + .build(); } public static McpSchema.ResourceTemplate asResourceTemplate(McpResource mcpResource) { @@ -74,10 +61,12 @@ public static McpSchema.ResourceTemplate asResourceTemplate(McpResource mcpResou name = "resource"; // Default name when not specified } var meta = MetaUtils.getMeta(mcpResource.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(mcpResource.annotations()); return McpSchema.ResourceTemplate.builder(mcpResource.uri(), name) .description(mcpResource.description()) .mimeType(mcpResource.mimeType()) + .annotations(annotations) .meta(meta) .build(); } diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/ResourceAnnotationsUtils.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/ResourceAnnotationsUtils.java new file mode 100644 index 0000000000..58e4f6cbec --- /dev/null +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/ResourceAnnotationsUtils.java @@ -0,0 +1,86 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.annotation.common; + +import java.lang.reflect.Method; +import java.util.List; + +import io.modelcontextprotocol.spec.McpSchema; + +import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpResource.McpAnnotations; + +/** + * Utility for converting the nested {@link McpAnnotations} value on a {@link McpResource} + * declaration into the wire-level {@link McpSchema.Annotations} record. + * + *

+ * Java annotation elements can never be {@code null}, so the compiler always materializes + * the declared default. This means every {@code @McpResource} instance appears to carry + * an "annotations" value regardless of whether the user actually set one. To avoid + * publishing that spurious default to MCP clients, this helper first compares the runtime + * value against the declared default of {@code McpResource#annotations()} — obtained via + * reflection so no default constants have to be duplicated — and returns {@code null} + * whenever they are equal (annotations use structural equality). Any user-supplied value + * is copied verbatim, including {@code audience}, {@code priority} and + * {@code lastModified}. + *

+ * + * @author Shiyang Chen + */ +public final class ResourceAnnotationsUtils { + + private static final McpAnnotations DEFAULT_ANNOTATIONS = resolveDefaultAnnotations(); + + private ResourceAnnotationsUtils() { + } + + /** + * Convert a {@link McpAnnotations} value declared on a {@link McpResource} into the + * corresponding {@link McpSchema.Annotations} payload. + * @param annotations the annotation value as returned by + * {@link McpResource#annotations()}. May be {@code null} for defensive use, in which + * case {@code null} is returned. + * @return the converted schema annotations, or {@code null} if {@code annotations} is + * {@code null} or equal to the declared default. + */ + public static McpSchema.Annotations toSchemaAnnotations(McpAnnotations annotations) { + if (annotations == null || annotations.equals(DEFAULT_ANNOTATIONS)) { + return null; + } + return McpSchema.Annotations.builder() + .audience(List.of(annotations.audience())) + .priority(annotations.priority()) + .lastModified(annotations.lastModified()) + .build(); + } + + private static McpAnnotations resolveDefaultAnnotations() { + try { + Method annotationsMethod = McpResource.class.getDeclaredMethod("annotations"); + Object defaultValue = annotationsMethod.getDefaultValue(); + if (defaultValue instanceof McpAnnotations mcpAnnotations) { + return mcpAnnotations; + } + return null; + } + catch (NoSuchMethodException ex) { + throw new IllegalStateException("McpResource#annotations() is expected to be defined", ex); + } + } + +} diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProvider.java index 1d87b3f5d5..3dacae1c3e 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProvider.java @@ -37,6 +37,7 @@ import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; +import org.springframework.ai.mcp.annotation.common.ResourceAnnotationsUtils; import org.springframework.ai.mcp.annotation.method.resource.AsyncMcpResourceMethodCallback; /** @@ -92,10 +93,12 @@ public List getResourceSpecifications() { var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResource = McpSchema.Resource.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); @@ -145,10 +148,12 @@ public List getResourceTemplateSpecification var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResourceTemplate = McpSchema.ResourceTemplate.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProvider.java index 2861a67d28..864290c277 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProvider.java @@ -36,6 +36,7 @@ import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; +import org.springframework.ai.mcp.annotation.common.ResourceAnnotationsUtils; import org.springframework.ai.mcp.annotation.method.resource.AsyncStatelessMcpResourceMethodCallback; /** @@ -92,10 +93,12 @@ public List getResourceSpecifications() { var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResource = McpSchema.Resource.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); @@ -145,10 +148,12 @@ public List getResourceTemplateSpecification var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResourceTemplate = McpSchema.ResourceTemplate.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProvider.java index c152c22ac0..84266cc224 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProvider.java @@ -29,6 +29,7 @@ import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; +import org.springframework.ai.mcp.annotation.common.ResourceAnnotationsUtils; import org.springframework.ai.mcp.annotation.method.resource.SyncMcpResourceMethodCallback; /** @@ -66,10 +67,12 @@ public List getResourceSpecifications() { var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResource = McpSchema.Resource.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); @@ -109,10 +112,12 @@ public List getResourceTemplateSpecifications var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResourceTemplate = McpSchema.ResourceTemplate.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProvider.java index d257ba3979..cf62bf8a4d 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProvider.java @@ -35,6 +35,7 @@ import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; +import org.springframework.ai.mcp.annotation.common.ResourceAnnotationsUtils; import org.springframework.ai.mcp.annotation.method.resource.SyncStatelessMcpResourceMethodCallback; /** @@ -91,10 +92,12 @@ public List getResourceSpecifications() { var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResource = McpSchema.Resource.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); @@ -144,10 +147,12 @@ public List getResourceTemplateSpecifications var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); + var annotations = ResourceAnnotationsUtils.toSchemaAnnotations(resourceAnnotation.annotations()); var mcpResourceTemplate = McpSchema.ResourceTemplate.builder(uri, name) .description(description) .mimeType(mimeType) + .annotations(annotations) .meta(meta) .build(); diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProviderTests.java index 36507fcb5b..240efb6a54 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncMcpResourceProviderTests.java @@ -24,12 +24,14 @@ import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; +import io.modelcontextprotocol.spec.McpSchema.Role; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpResource.McpAnnotations; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -495,4 +497,71 @@ public Mono syncMethodReturningMono() { }).verifyComplete(); } + @Test + void testDefaultAnnotationsAreNotPropagated() { + class NoAnnotationsResource { + + @McpResource(uri = "no-ann://resource", name = "no-ann") + public Mono noAnnotations() { + return Mono.just(""); + } + + } + + AsyncMcpResourceProvider provider = new AsyncMcpResourceProvider(List.of(new NoAnnotationsResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + assertThat(resourceSpecs.get(0).resource().annotations()).isNull(); + } + + @Test + void testExplicitAnnotationsArePropagatedToResource() { + class AnnotatedResource { + + @McpResource(uri = "ann://resource", name = "ann", + annotations = @McpAnnotations(audience = { Role.ASSISTANT }, priority = 1.0, + lastModified = "2026-08-16T00:00:00Z")) + public Mono annotated() { + return Mono.just(""); + } + + } + + AsyncMcpResourceProvider provider = new AsyncMcpResourceProvider(List.of(new AnnotatedResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + var annotations = resourceSpecs.get(0).resource().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(1.0); + assertThat(annotations.lastModified()).isEqualTo("2026-08-16T00:00:00Z"); + } + + @Test + void testExplicitAnnotationsArePropagatedToResourceTemplate() { + class AnnotatedTemplate { + + @McpResource(uri = "ann://template/{id}", name = "ann-template", + annotations = @McpAnnotations(audience = { Role.USER, Role.ASSISTANT }, priority = 0.25)) + public Mono annotatedTemplate(String id) { + return Mono.just(id); + } + + } + + AsyncMcpResourceProvider provider = new AsyncMcpResourceProvider(List.of(new AnnotatedTemplate())); + + List resourceTemplateSpecs = provider.getResourceTemplateSpecifications(); + + assertThat(resourceTemplateSpecs).hasSize(1); + var annotations = resourceTemplateSpecs.get(0).resourceTemplate().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.USER, Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(0.25); + } + } diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProviderTests.java index 007a6821b5..f174b90a7f 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/AsyncStatelessMcpResourceProviderTests.java @@ -24,12 +24,14 @@ import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; +import io.modelcontextprotocol.spec.McpSchema.Role; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpResource.McpAnnotations; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -496,4 +498,74 @@ public Mono syncMethodReturningMono() { }).verifyComplete(); } + @Test + void testDefaultAnnotationsAreNotPropagated() { + class NoAnnotationsResource { + + @McpResource(uri = "no-ann://resource", name = "no-ann") + public Mono noAnnotations() { + return Mono.just(""); + } + + } + + AsyncStatelessMcpResourceProvider provider = new AsyncStatelessMcpResourceProvider( + List.of(new NoAnnotationsResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + assertThat(resourceSpecs.get(0).resource().annotations()).isNull(); + } + + @Test + void testExplicitAnnotationsArePropagatedToResource() { + class AnnotatedResource { + + @McpResource(uri = "ann://resource", name = "ann", + annotations = @McpAnnotations(audience = { Role.ASSISTANT }, priority = 1.0, + lastModified = "2026-08-16T00:00:00Z")) + public Mono annotated() { + return Mono.just(""); + } + + } + + AsyncStatelessMcpResourceProvider provider = new AsyncStatelessMcpResourceProvider( + List.of(new AnnotatedResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + var annotations = resourceSpecs.get(0).resource().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(1.0); + assertThat(annotations.lastModified()).isEqualTo("2026-08-16T00:00:00Z"); + } + + @Test + void testExplicitAnnotationsArePropagatedToResourceTemplate() { + class AnnotatedTemplate { + + @McpResource(uri = "ann://template/{id}", name = "ann-template", + annotations = @McpAnnotations(audience = { Role.USER, Role.ASSISTANT }, priority = 0.25)) + public Mono annotatedTemplate(String id) { + return Mono.just(id); + } + + } + + AsyncStatelessMcpResourceProvider provider = new AsyncStatelessMcpResourceProvider( + List.of(new AnnotatedTemplate())); + + List resourceTemplateSpecs = provider.getResourceTemplateSpecifications(); + + assertThat(resourceTemplateSpecs).hasSize(1); + var annotations = resourceTemplateSpecs.get(0).resourceTemplate().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.USER, Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(0.25); + } + } diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProviderTests.java index ced6075d6e..66566d9fe8 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncMcpResourceProviderTests.java @@ -25,11 +25,13 @@ import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; +import io.modelcontextprotocol.spec.McpSchema.Role; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpResource.McpAnnotations; import org.springframework.ai.mcp.annotation.context.MetaProvider; import static org.assertj.core.api.Assertions.assertThat; @@ -531,6 +533,97 @@ public String noParamResource() { assertThat(((TextResourceContents) content).text()).isEqualTo("No parameters needed"); } + @Test + void testDefaultAnnotationsAreNotPropagated() { + class NoAnnotationsResource { + + @McpResource(uri = "no-ann://resource", name = "no-ann", description = "no annotations") + public String noAnnotations() { + return ""; + } + + } + + SyncMcpResourceProvider provider = new SyncMcpResourceProvider(List.of(new NoAnnotationsResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + assertThat(resourceSpecs.get(0).resource().annotations()).isNull(); + } + + @Test + void testExplicitAnnotationsArePropagatedToResource() { + class AnnotatedResource { + + @McpResource(uri = "ann://resource", name = "ann", description = "explicit annotations", + annotations = @McpAnnotations(audience = { Role.ASSISTANT }, priority = 1.0, + lastModified = "2026-08-16T00:00:00Z")) + public String annotated() { + return ""; + } + + } + + SyncMcpResourceProvider provider = new SyncMcpResourceProvider(List.of(new AnnotatedResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + var annotations = resourceSpecs.get(0).resource().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(1.0); + assertThat(annotations.lastModified()).isEqualTo("2026-08-16T00:00:00Z"); + } + + @Test + void testExplicitAnnotationsArePropagatedToResourceTemplate() { + class AnnotatedTemplate { + + @McpResource(uri = "ann://template/{id}", name = "ann-template", description = "annotated template", + annotations = @McpAnnotations(audience = { Role.USER, Role.ASSISTANT }, priority = 0.25)) + public String annotatedTemplate(String id) { + return id; + } + + } + + SyncMcpResourceProvider provider = new SyncMcpResourceProvider(List.of(new AnnotatedTemplate())); + + List resourceTemplateSpecs = provider.getResourceTemplateSpecifications(); + + assertThat(resourceTemplateSpecs).hasSize(1); + var annotations = resourceTemplateSpecs.get(0).resourceTemplate().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.USER, Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(0.25); + } + + @Test + void testAudienceOnlyChangeIsPropagated() { + class AudienceOnly { + + @McpResource(uri = "audience-only://resource", name = "audience-only", + annotations = @McpAnnotations(audience = { Role.ASSISTANT }, priority = 0.5)) + public String audienceOnly() { + return ""; + } + + } + + SyncMcpResourceProvider provider = new SyncMcpResourceProvider(List.of(new AudienceOnly())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + var annotations = resourceSpecs.get(0).resource().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(0.5); + assertThat(annotations.lastModified()).isNullOrEmpty(); + } + public static class ResourceMetaProvider implements MetaProvider { @Override diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProviderTests.java index 2bf25e55b0..f57e7a0708 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/resource/SyncStatelessMcpResourceProviderTests.java @@ -24,11 +24,13 @@ import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; +import io.modelcontextprotocol.spec.McpSchema.Role; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import org.springframework.ai.mcp.annotation.McpResource; +import org.springframework.ai.mcp.annotation.McpResource.McpAnnotations; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -458,4 +460,74 @@ public String requestResource(ReadResourceRequest request) { assertThat(((TextResourceContents) content).text()).isEqualTo("Resource for URI: request://resource"); } + @Test + void testDefaultAnnotationsAreNotPropagated() { + class NoAnnotationsResource { + + @McpResource(uri = "no-ann://resource", name = "no-ann") + public String noAnnotations() { + return ""; + } + + } + + SyncStatelessMcpResourceProvider provider = new SyncStatelessMcpResourceProvider( + List.of(new NoAnnotationsResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + assertThat(resourceSpecs.get(0).resource().annotations()).isNull(); + } + + @Test + void testExplicitAnnotationsArePropagatedToResource() { + class AnnotatedResource { + + @McpResource(uri = "ann://resource", name = "ann", + annotations = @McpAnnotations(audience = { Role.ASSISTANT }, priority = 1.0, + lastModified = "2026-08-16T00:00:00Z")) + public String annotated() { + return ""; + } + + } + + SyncStatelessMcpResourceProvider provider = new SyncStatelessMcpResourceProvider( + List.of(new AnnotatedResource())); + + List resourceSpecs = provider.getResourceSpecifications(); + + assertThat(resourceSpecs).hasSize(1); + var annotations = resourceSpecs.get(0).resource().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(1.0); + assertThat(annotations.lastModified()).isEqualTo("2026-08-16T00:00:00Z"); + } + + @Test + void testExplicitAnnotationsArePropagatedToResourceTemplate() { + class AnnotatedTemplate { + + @McpResource(uri = "ann://template/{id}", name = "ann-template", + annotations = @McpAnnotations(audience = { Role.USER, Role.ASSISTANT }, priority = 0.25)) + public String annotatedTemplate(String id) { + return id; + } + + } + + SyncStatelessMcpResourceProvider provider = new SyncStatelessMcpResourceProvider( + List.of(new AnnotatedTemplate())); + + List resourceTemplateSpecs = provider.getResourceTemplateSpecifications(); + + assertThat(resourceTemplateSpecs).hasSize(1); + var annotations = resourceTemplateSpecs.get(0).resourceTemplate().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.audience()).containsExactly(Role.USER, Role.ASSISTANT); + assertThat(annotations.priority()).isEqualTo(0.25); + } + }