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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) {
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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}.
* </p>
*
* @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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -92,10 +93,12 @@ public List<AsyncResourceSpecification> 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();

Expand Down Expand Up @@ -145,10 +148,12 @@ public List<AsyncResourceTemplateSpecification> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -92,10 +93,12 @@ public List<AsyncResourceSpecification> 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();

Expand Down Expand Up @@ -145,10 +148,12 @@ public List<AsyncResourceTemplateSpecification> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -66,10 +67,12 @@ public List<SyncResourceSpecification> 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();

Expand Down Expand Up @@ -109,10 +112,12 @@ public List<SyncResourceTemplateSpecification> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -91,10 +92,12 @@ public List<SyncResourceSpecification> 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();

Expand Down Expand Up @@ -144,10 +147,12 @@ public List<SyncResourceTemplateSpecification> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -495,4 +497,71 @@ public Mono<String> syncMethodReturningMono() {
}).verifyComplete();
}

@Test
void testDefaultAnnotationsAreNotPropagated() {
class NoAnnotationsResource {

@McpResource(uri = "no-ann://resource", name = "no-ann")
public Mono<String> noAnnotations() {
return Mono.just("");
}

}

AsyncMcpResourceProvider provider = new AsyncMcpResourceProvider(List.of(new NoAnnotationsResource()));

List<AsyncResourceSpecification> 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<String> annotated() {
return Mono.just("");
}

}

AsyncMcpResourceProvider provider = new AsyncMcpResourceProvider(List.of(new AnnotatedResource()));

List<AsyncResourceSpecification> 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<String> annotatedTemplate(String id) {
return Mono.just(id);
}

}

AsyncMcpResourceProvider provider = new AsyncMcpResourceProvider(List.of(new AnnotatedTemplate()));

List<AsyncResourceTemplateSpecification> 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);
}

}
Loading