From a32ae15cb913bf15c3715e038b5f9de1cc384e58 Mon Sep 17 00:00:00 2001 From: Vadzim Shurmialiou Date: Fri, 7 Nov 2025 16:22:48 +0100 Subject: [PATCH] feat: Add _meta to tool/prompt/resource declaration Append the ability to define "_meta" property in tool, prompt, resource declarations Usage: ``` @McpTool(metaProvider = MyMetaProvider.class) String myTool() { } class MyMetaProvider implements MetaProvider { return Map.of( "openai/widgetPrefersBorder", true, "openai/widgetDomain", 'https://chatgpt.com', "openai/widgetCSP", new WidgetCsp()); } ``` Signed-off-by: Vadzim Shurmialiou --- .../org/springaicommunity/mcp/MetaUtils.java | 87 +++++++++++++++ .../mcp/adapter/PromptAdapter.java | 9 +- .../mcp/annotation/McpPrompt.java | 10 ++ .../mcp/annotation/McpResource.java | 9 ++ .../mcp/annotation/McpTool.java | 10 ++ .../mcp/context/DefaultMetaProvider.java | 29 +++++ .../mcp/context/MetaProvider.java | 20 ++++ .../resource/AsyncMcpResourceProvider.java | 18 ++-- .../AsyncStatelessMcpResourceProvider.java | 3 + .../resource/SyncMcpResourceProvider.java | 3 + .../SyncStatelessMcpResourceProvider.java | 3 + .../provider/tool/AsyncMcpToolProvider.java | 14 ++- .../tool/AsyncStatelessMcpToolProvider.java | 14 ++- .../provider/tool/SyncMcpToolProvider.java | 12 ++- .../tool/SyncStatelessMcpToolProvider.java | 14 ++- .../springaicommunity/mcp/MetaUtilsTest.java | 100 ++++++++++++++++++ .../mcp/context/DefaultMetaProviderTest.java | 22 ++++ .../AsyncMcpResourceMethodCallbackTests.java | 12 +++ ...atelessMcpResourceMethodCallbackTests.java | 12 +++ .../McpResourceUriValidationTest.java | 12 +++ .../SyncMcpResourceMethodCallbackTests.java | 12 +++ ...atelessMcpResourceMethodCallbackTests.java | 12 +++ 22 files changed, 413 insertions(+), 24 deletions(-) create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/MetaUtils.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/context/DefaultMetaProvider.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/context/MetaProvider.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/MetaUtilsTest.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/context/DefaultMetaProviderTest.java diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/MetaUtils.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/MetaUtils.java new file mode 100644 index 0000000..93f6640 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/MetaUtils.java @@ -0,0 +1,87 @@ +package org.springaicommunity.mcp; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.Collections; +import java.util.Map; + +import org.springaicommunity.mcp.context.MetaProvider; + +/** + * Utility methods for working with {@link MetaProvider} metadata. + * + *

+ * This class provides a single entry point {@link #getMeta(Class)} that instantiates the + * given provider type via a no-argument constructor and returns its metadata as an + * unmodifiable {@link Map}. + *

+ * + *

+ * Instantiation failures and missing no-arg constructors are reported as + * {@link IllegalArgumentException IllegalArgumentExceptions}. This class is stateless and + * not intended to be instantiated. + *

+ */ +public final class MetaUtils { + + /** Not intended to be instantiated. */ + private MetaUtils() { + } + + /** + * Instantiate the supplied {@link MetaProvider} type using a no-argument constructor + * and return the metadata it supplies. + *

+ * The returned map is wrapped in {@link Collections#unmodifiableMap(Map)} to prevent + * external modification. If the provider returns {@code null}, this method also + * returns {@code null}. + * @param metaProviderClass the {@code MetaProvider} implementation class to + * instantiate; must provide a no-arg constructor + * @return an unmodifiable metadata map, or {@code null} if the provider returns + * {@code null} + * @throws IllegalArgumentException if a no-arg constructor is missing or the instance + * cannot be created + */ + public static Map getMeta(Class metaProviderClass) { + + if (metaProviderClass == null) { + return null; + } + + String className = metaProviderClass.getName(); + MetaProvider metaProvider; + try { + // Prefer a public no-arg constructor; fall back to a declared no-arg if + // accessible + Constructor constructor = getConstructor(metaProviderClass); + metaProvider = constructor.newInstance(); + } + catch (NoSuchMethodException e) { + throw new IllegalArgumentException("Required no-arg constructor not found in " + className, e); + } + catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { + throw new IllegalArgumentException(className + " instantiation failed", e); + } + + Map meta = metaProvider.getMeta(); + return meta == null ? null : Collections.unmodifiableMap(meta); + } + + /** + * Locate a no-argument constructor on the given class: prefer public, otherwise fall + * back to a declared no-arg constructor. + * @param metaProviderClass the class to inspect + * @return the resolved no-arg constructor + * @throws NoSuchMethodException if the class does not declare any no-arg constructor + */ + private static Constructor getConstructor(Class metaProviderClass) + throws NoSuchMethodException { + try { + return metaProviderClass.getDeclaredConstructor(); + } + catch (NoSuchMethodException ex) { + return metaProviderClass.getConstructor(); + } + } + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/adapter/PromptAdapter.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/adapter/PromptAdapter.java index 5d0c428..8c870d8 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/adapter/PromptAdapter.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/adapter/PromptAdapter.java @@ -7,9 +7,11 @@ import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.List; +import java.util.Map; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.util.Assert; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpPrompt; @@ -29,7 +31,8 @@ private PromptAdapter() { * @return The corresponding McpSchema.Prompt object */ public static McpSchema.Prompt asPrompt(McpPrompt mcpPrompt) { - return new McpSchema.Prompt(mcpPrompt.name(), mcpPrompt.title(), mcpPrompt.description(), List.of()); + Map meta = MetaUtils.getMeta(mcpPrompt.metaProvider()); + return new McpSchema.Prompt(mcpPrompt.name(), mcpPrompt.title(), mcpPrompt.description(), List.of(), meta); } /** @@ -41,7 +44,9 @@ public static McpSchema.Prompt asPrompt(McpPrompt mcpPrompt) { */ public static McpSchema.Prompt asPrompt(McpPrompt mcpPrompt, Method method) { List arguments = extractPromptArguments(method); - return new McpSchema.Prompt(getName(mcpPrompt, method), mcpPrompt.title(), mcpPrompt.description(), arguments); + Map meta = MetaUtils.getMeta(mcpPrompt.metaProvider()); + return new McpSchema.Prompt(getName(mcpPrompt, method), mcpPrompt.title(), mcpPrompt.description(), arguments, + meta); } private static String getName(McpPrompt promptAnnotation, Method method) { diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPrompt.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPrompt.java index 5002e7f..65531c5 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPrompt.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPrompt.java @@ -4,6 +4,9 @@ package org.springaicommunity.mcp.annotation; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; + import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -35,4 +38,11 @@ */ String description() default ""; + /** + * Optional meta provider class that implements the MetaProvider interface. Used to + * provide additional metadata for the prompt. Defaults to {@link DefaultMetaProvider + * DefaultMetaProvider.class} if not specified. + */ + Class metaProvider() default DefaultMetaProvider.class; + } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResource.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResource.java index 40e3e0d..210ed4b 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResource.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResource.java @@ -11,6 +11,8 @@ import java.lang.annotation.Target; import io.modelcontextprotocol.spec.McpSchema.Role; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; /** * Marks a method as a MCP Resource. @@ -56,6 +58,13 @@ */ McpAnnotations annotations() default @McpAnnotations(audience = { Role.USER }, lastModified = "", priority = 0.5); + /** + * Optional meta provider class that supplies data for "_meta" field for this resource + * declaration. Defaults to {@link DefaultMetaProvider} implementation. + * @return the meta provider class to use for this resource + */ + Class metaProvider() default DefaultMetaProvider.class; + @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.ANNOTATION_TYPE) public @interface McpAnnotations { diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java index 8557623..dcf21ec 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java @@ -3,11 +3,15 @@ */ package org.springaicommunity.mcp.annotation; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; + import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.Map; /** * @author Christian Tzolov @@ -46,6 +50,12 @@ */ String title() default ""; + /** + * "_meta" field for the tool declaration. If not provided, no "_meta" appended to the + * tool specification. + */ + Class metaProvider() default DefaultMetaProvider.class; + /** * Additional properties describing a Tool to clients. * diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/context/DefaultMetaProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/context/DefaultMetaProvider.java new file mode 100644 index 0000000..766a059 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/context/DefaultMetaProvider.java @@ -0,0 +1,29 @@ +package org.springaicommunity.mcp.context; + +import java.util.Map; + +/** + * Default {@link MetaProvider} implementation that disables the "_meta" field in tool, + * prompt, resource declarations. + * + *

+ * This provider deliberately returns {@code null} from {@link #getMeta()} to signal that + * no "_meta" information is included. + *

+ * + *

+ * Use this when your tool, prompt, or resource does not need to expose any meta + * information or you want to keep responses minimal by default. + *

+ */ +public class DefaultMetaProvider implements MetaProvider { + + /** + * Returns {@code null} to indicate that no "_meta" field should be included in. + */ + @Override + public Map getMeta() { + return null; + } + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/context/MetaProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/context/MetaProvider.java new file mode 100644 index 0000000..0d37863 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/context/MetaProvider.java @@ -0,0 +1,20 @@ +package org.springaicommunity.mcp.context; + +import java.util.Map; + +/** + * Common interface for classes that provide metadata for the "_meta" field. This metadata + * is used in tool, prompt, and resource declarations. + */ +public interface MetaProvider { + + /** + * Returns metadata key-value pairs that will be included in the "_meta" field. These + * metadata values provide additional context and information for tools, prompts, and + * resource declarations. + * @return A Map containing metadata key-value pairs, where keys are strings and + * values can be any object type. + */ + Map getMeta(); + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncMcpResourceProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncMcpResourceProvider.java index 87c91b7..e5345a7 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncMcpResourceProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncMcpResourceProvider.java @@ -16,12 +16,6 @@ package org.springaicommunity.mcp.provider.resource; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Objects; -import java.util.function.BiFunction; -import java.util.stream.Stream; - import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceSpecification; import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceTemplateSpecification; @@ -32,10 +26,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.method.resource.AsyncMcpResourceMethodCallback; import reactor.core.publisher.Mono; +import java.lang.reflect.Method; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.stream.Stream; + /** * Provider for asynchronous MCP resource methods. * @@ -71,7 +73,7 @@ public List getResourceSpecifications() { .map(resourceObject -> Stream.of(doGetClassMethods(resourceObject)) .filter(method -> method.isAnnotationPresent(McpResource.class)) .filter(McpPredicates.filterNonReactiveReturnTypeMethod()) - .sorted((m1, m2) -> m1.getName().compareTo(m2.getName())) + .sorted(Comparator.comparing(Method::getName)) .map(mcpResourceMethod -> { var resourceAnnotation = doGetMcpResourceAnnotation(mcpResourceMethod); @@ -85,12 +87,14 @@ public List getResourceSpecifications() { var name = getName(mcpResourceMethod, resourceAnnotation); var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); + var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); var mcpResource = McpSchema.Resource.builder() .uri(uri) .name(name) .description(description) .mimeType(mimeType) + .meta(meta) .build(); BiFunction> methodCallback = AsyncMcpResourceMethodCallback diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncStatelessMcpResourceProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncStatelessMcpResourceProvider.java index 4674236..e2c2fc6 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncStatelessMcpResourceProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/AsyncStatelessMcpResourceProvider.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.method.resource.AsyncStatelessMcpResourceMethodCallback; import reactor.core.publisher.Mono; @@ -86,12 +87,14 @@ public List getResourceSpecifications() { var name = getName(mcpResourceMethod, resourceAnnotation); var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); + var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); var mcpResource = McpSchema.Resource.builder() .uri(uri) .name(name) .description(description) .mimeType(mimeType) + .meta(meta) .build(); BiFunction> methodCallback = AsyncStatelessMcpResourceMethodCallback diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncMcpResourceProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncMcpResourceProvider.java index ad9fd15..27a8409 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncMcpResourceProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncMcpResourceProvider.java @@ -26,6 +26,7 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.util.Assert; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.method.resource.SyncMcpResourceMethodCallback; @@ -59,12 +60,14 @@ public List getResourceSpecifications() { var name = getName(mcpResourceMethod, resourceAnnotation); var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); + var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); var mcpResource = McpSchema.Resource.builder() .uri(uri) .name(name) .description(description) .mimeType(mimeType) + .meta(meta) .build(); var methodCallback = SyncMcpResourceMethodCallback.builder() diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncStatelessMcpResourceProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncStatelessMcpResourceProvider.java index 6905cd0..d0ca8f4 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncStatelessMcpResourceProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/resource/SyncStatelessMcpResourceProvider.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.method.resource.SyncStatelessMcpResourceMethodCallback; @@ -85,12 +86,14 @@ public List getResourceSpecifications() { var name = getName(mcpResourceMethod, resourceAnnotation); var description = resourceAnnotation.description(); var mimeType = resourceAnnotation.mimeType(); + var meta = MetaUtils.getMeta(resourceAnnotation.metaProvider()); var mcpResource = McpSchema.Resource.builder() .uri(uri) .name(name) .description(description) .mimeType(mimeType) + .meta(meta) .build(); BiFunction methodCallback = SyncStatelessMcpResourceMethodCallback diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java index 0be1e8d..8475936 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java @@ -16,6 +16,8 @@ package org.springaicommunity.mcp.provider.tool; +import java.lang.reflect.Method; +import java.util.Comparator; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -29,6 +31,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.method.tool.AsyncMcpToolMethodCallback; import org.springaicommunity.mcp.method.tool.ReactiveUtils; @@ -64,7 +67,7 @@ public List getToolSpecifications() { .map(toolObject -> Stream.of(this.doGetClassMethods(toolObject)) .filter(method -> method.isAnnotationPresent(McpTool.class)) .filter(McpPredicates.filterNonReactiveReturnTypeMethod()) - .sorted((m1, m2) -> m1.getName().compareTo(m2.getName())) + .sorted(Comparator.comparing(Method::getName)) .map(mcpToolMethod -> { var toolJavaAnnotation = this.doGetMcpToolAnnotation(mcpToolMethod); @@ -72,14 +75,17 @@ public List getToolSpecifications() { String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescrption = toolJavaAnnotation.description(); + String toolDescription = toolJavaAnnotation.description(); String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); + var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var toolBuilder = McpSchema.Tool.builder() .name(toolName) - .description(toolDescrption) - .inputSchema(this.getJsonMapper(), inputSchema); + .description(toolDescription) + .inputSchema(this.getJsonMapper(), inputSchema) + .meta(meta); var title = toolJavaAnnotation.title(); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java index bfc7049..37ef14d 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java @@ -16,6 +16,8 @@ package org.springaicommunity.mcp.provider.tool; +import java.lang.reflect.Method; +import java.util.Comparator; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -29,6 +31,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.method.tool.AsyncStatelessMcpToolMethodCallback; import org.springaicommunity.mcp.method.tool.ReactiveUtils; @@ -69,7 +72,7 @@ public List getToolSpecifications() { .filter(method -> method.isAnnotationPresent(McpTool.class)) .filter(McpPredicates.filterNonReactiveReturnTypeMethod()) .filter(McpPredicates.filterMethodWithBidirectionalParameters()) - .sorted((m1, m2) -> m1.getName().compareTo(m2.getName())) + .sorted(Comparator.comparing(Method::getName)) .map(mcpToolMethod -> { var toolJavaAnnotation = doGetMcpToolAnnotation(mcpToolMethod); @@ -77,14 +80,17 @@ public List getToolSpecifications() { String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescrption = toolJavaAnnotation.description(); + String toolDescription = toolJavaAnnotation.description(); String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); + var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var toolBuilder = McpSchema.Tool.builder() .name(toolName) - .description(toolDescrption) - .inputSchema(this.getJsonMapper(), inputSchema); + .description(toolDescription) + .inputSchema(this.getJsonMapper(), inputSchema) + .meta(meta); var title = toolJavaAnnotation.title(); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java index e209b73..8b2efd9 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java @@ -16,6 +16,8 @@ package org.springaicommunity.mcp.provider.tool; +import java.lang.reflect.Method; +import java.util.Comparator; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -29,6 +31,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.method.tool.ReturnMode; import org.springaicommunity.mcp.method.tool.SyncMcpToolMethodCallback; @@ -62,7 +65,7 @@ public List getToolSpecifications() { .map(toolObject -> Stream.of(this.doGetClassMethods(toolObject)) .filter(method -> method.isAnnotationPresent(McpTool.class)) .filter(McpPredicates.filterReactiveReturnTypeMethod()) - .sorted((m1, m2) -> m1.getName().compareTo(m2.getName())) + .sorted(Comparator.comparing(Method::getName)) .map(mcpToolMethod -> { McpTool toolJavaAnnotation = this.doGetMcpToolAnnotation(mcpToolMethod); @@ -74,10 +77,13 @@ public List getToolSpecifications() { String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); - McpSchema.Tool.Builder toolBuilder = McpSchema.Tool.builder() + var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + + var toolBuilder = McpSchema.Tool.builder() .name(toolName) .description(toolDescription) - .inputSchema(this.getJsonMapper(), inputSchema); + .inputSchema(this.getJsonMapper(), inputSchema) + .meta(meta); var title = toolJavaAnnotation.title(); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java index c1ce5f6..e7fb5f8 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java @@ -16,6 +16,8 @@ package org.springaicommunity.mcp.provider.tool; +import java.lang.reflect.Method; +import java.util.Comparator; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -29,6 +31,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springaicommunity.mcp.McpPredicates; +import org.springaicommunity.mcp.MetaUtils; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.method.tool.ReturnMode; import org.springaicommunity.mcp.method.tool.SyncStatelessMcpToolMethodCallback; @@ -66,7 +69,7 @@ public List getToolSpecifications() { .filter(method -> method.isAnnotationPresent(McpTool.class)) .filter(McpPredicates.filterReactiveReturnTypeMethod()) .filter(McpPredicates.filterMethodWithBidirectionalParameters()) - .sorted((m1, m2) -> m1.getName().compareTo(m2.getName())) + .sorted(Comparator.comparing(Method::getName)) .map(mcpToolMethod -> { var toolJavaAnnotation = this.doGetMcpToolAnnotation(mcpToolMethod); @@ -74,14 +77,17 @@ public List getToolSpecifications() { String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescrption = toolJavaAnnotation.description(); + String toolDescription = toolJavaAnnotation.description(); String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); + var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var toolBuilder = McpSchema.Tool.builder() .name(toolName) - .description(toolDescrption) - .inputSchema(this.getJsonMapper(), inputSchema); + .description(toolDescription) + .inputSchema(this.getJsonMapper(), inputSchema) + .meta(meta); var title = toolJavaAnnotation.title(); diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/MetaUtilsTest.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/MetaUtilsTest.java new file mode 100644 index 0000000..752fa3b --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/MetaUtilsTest.java @@ -0,0 +1,100 @@ +package org.springaicommunity.mcp; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class MetaUtilsTest { + + @Test + void testGetMetaNonNull() { + + Map actual = MetaUtils.getMeta(MetaProviderWithDefaultConstructor.class); + + assertThat(actual).containsExactlyInAnyOrderEntriesOf(new MetaProviderWithDefaultConstructor().getMeta()); + } + + @Test + void testGetMetaWithPublicConstructor() { + + Map actual = MetaUtils.getMeta(MetaProviderWithAvailableConstructor.class); + + assertThat(actual).containsExactlyInAnyOrderEntriesOf(new MetaProviderWithAvailableConstructor().getMeta()); + } + + @Test + void testGetMetaWithUnavailableConstructor() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> MetaUtils.getMeta(MetaProviderWithUnavailableConstructor.class)) + .withMessage( + "org.springaicommunity.mcp.MetaUtilsTest$MetaProviderWithUnavailableConstructor instantiation failed"); + } + + @Test + void testGetMetaWithConstructorWithWrongSignature() { + + assertThatIllegalArgumentException() + .isThrownBy(() -> MetaUtils.getMeta(MetaProviderWithConstructorWithWrongSignature.class)) + .withMessage( + "Required no-arg constructor not found in org.springaicommunity.mcp.MetaUtilsTest$MetaProviderWithConstructorWithWrongSignature"); + } + + @Test + void testGetMetaNull() { + + Map actual = MetaUtils.getMeta(DefaultMetaProvider.class); + + assertThat(actual).isNull(); + } + + @Test + void testMetaProviderClassIsNullReturnsNull() { + + Map actual = MetaUtils.getMeta(null); + + assertThat(actual).isNull(); + } + + static class MetaProviderWithDefaultConstructor implements MetaProvider { + + @Override + public Map getMeta() { + return Map.of("a", "1", "b", "2"); + } + + } + + @SuppressWarnings("unused") + static class MetaProviderWithAvailableConstructor extends MetaProviderWithDefaultConstructor { + + public MetaProviderWithAvailableConstructor() { + // Nothing to do here + } + + } + + @SuppressWarnings("unused") + static class MetaProviderWithUnavailableConstructor extends MetaProviderWithDefaultConstructor { + + private MetaProviderWithUnavailableConstructor() { + // Nothing to do here + } + + } + + @SuppressWarnings("unused") + static class MetaProviderWithConstructorWithWrongSignature extends MetaProviderWithDefaultConstructor { + + private MetaProviderWithConstructorWithWrongSignature(int invalid) { + // Nothing to do here + } + + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/context/DefaultMetaProviderTest.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/context/DefaultMetaProviderTest.java new file mode 100644 index 0000000..77a9866 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/context/DefaultMetaProviderTest.java @@ -0,0 +1,22 @@ +package org.springaicommunity.mcp.context; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class DefaultMetaProviderTest { + + @Test + void testGetMetaReturningNull() { + + DefaultMetaProvider provider = new DefaultMetaProvider(); + + Map actual = provider.getMeta(); + + assertThat(actual).isNull(); + } + +} \ No newline at end of file diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallbackTests.java index 7234cc1..33142b6 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallbackTests.java @@ -26,8 +26,10 @@ import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.context.DefaultMetaProvider; import org.springaicommunity.mcp.context.McpAsyncRequestContext; import org.springaicommunity.mcp.context.McpSyncRequestContext; +import org.springaicommunity.mcp.context.MetaProvider; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -337,6 +339,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; } @@ -676,6 +683,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; assertThatThrownBy(() -> AsyncMcpResourceMethodCallback.builder() diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallbackTests.java index be03bea..cbf79ff 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallbackTests.java @@ -26,6 +26,8 @@ import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -266,6 +268,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; } @@ -611,6 +618,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; assertThatThrownBy(() -> AsyncStatelessMcpResourceMethodCallback.builder() diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/McpResourceUriValidationTest.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/McpResourceUriValidationTest.java index 5cb4ad4..4692e29 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/McpResourceUriValidationTest.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/McpResourceUriValidationTest.java @@ -12,6 +12,8 @@ import io.modelcontextprotocol.spec.McpSchema.Role; import org.springaicommunity.mcp.adapter.ResourceAdapter; import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; /** * Simple test to verify that McpResourceMethodCallback requires a non-empty URI in the @@ -90,6 +92,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; } @@ -150,6 +157,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallbackTests.java index 92da973..e1176b0 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallbackTests.java @@ -23,9 +23,11 @@ import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.context.DefaultMetaProvider; import org.springaicommunity.mcp.context.McpAsyncRequestContext; import org.springaicommunity.mcp.context.McpSyncRequestContext; +import org.springaicommunity.mcp.context.MetaProvider; import reactor.core.publisher.Mono; import static org.assertj.core.api.Assertions.assertThat; @@ -315,6 +317,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; } @@ -615,6 +622,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; assertThatThrownBy(() -> SyncMcpResourceMethodCallback.builder() diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallbackTests.java index eee5647..c551695 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallbackTests.java @@ -24,6 +24,8 @@ import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; +import org.springaicommunity.mcp.context.DefaultMetaProvider; +import org.springaicommunity.mcp.context.MetaProvider; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -248,6 +250,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; } @@ -660,6 +667,11 @@ public double priority() { } }; } + + @Override + public Class metaProvider() { + return DefaultMetaProvider.class; + } }; assertThatThrownBy(() -> SyncStatelessMcpResourceMethodCallback.builder()