diff --git a/README.md b/README.md index d8a2d0a..3d95045 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ The Spring integration module provides seamless integration with Spring AI and S #### Special Parameter Annotations - **`@McpProgressToken`** - Marks a method parameter to receive the progress token from the request. This parameter is automatically injected and excluded from the generated JSON schema +- **`McpMeta`** - Special parameter type that provides access to metadata from MCP requests, notifications, and results. This parameter is automatically injected and excluded from parameter count limits and JSON schema generation ### Method Callbacks @@ -638,6 +639,136 @@ public List completeWithProgress( This feature enables better tracking and monitoring of MCP operations, especially for long-running tasks that need to report progress back to clients. +#### McpMeta Support + +The `McpMeta` class provides access to metadata from MCP requests, notifications, and results. This is useful for accessing contextual information that clients may include with their requests. + +When a method parameter is of type `McpMeta`: +- The parameter automatically receives metadata from the request wrapped in an `McpMeta` object +- The parameter is excluded from parameter count limits and JSON schema generation +- The parameter provides convenient access to metadata through the `get(String key)` method +- If no metadata is present in the request, an empty `McpMeta` object is injected + +Example usage with tools: + +```java +@McpTool(name = "personalized-task", description = "Performs a task with user context") +public String personalizedTask( + @McpToolParam(description = "Task name", required = true) String taskName, + McpMeta meta) { + + // Access metadata from the request + String userId = (String) meta.get("userId"); + String sessionId = (String) meta.get("sessionId"); + + if (userId != null) { + return "Task " + taskName + " executed for user: " + userId + + " (session: " + sessionId + ")"; + } + + return "Task " + taskName + " executed (no user context)"; +} + +// Tool with both CallToolRequest and McpMeta +@McpTool(name = "flexible-task", description = "Flexible task with metadata") +public CallToolResult flexibleTask( + CallToolRequest request, + McpMeta meta) { + + // Access both the full request and metadata + Map args = request.arguments(); + String userRole = (String) meta.get("userRole"); + + String result = "Processed " + args.size() + " arguments"; + if (userRole != null) { + result += " for user with role: " + userRole; + } + + return CallToolResult.builder() + .addTextContent(result) + .build(); +} +``` + +The `McpMeta` parameter is also supported in other MCP callback types: + +**Resource callbacks:** +```java +@McpResource(uri = "user-data://{id}", name = "User Data", description = "User data with context") +public ReadResourceResult getUserData( + String id, + McpMeta meta) { + + String requestingUser = (String) meta.get("requestingUser"); + String accessLevel = (String) meta.get("accessLevel"); + + // Use metadata to customize response based on requesting user + String content = "User data for " + id; + if ("admin".equals(accessLevel)) { + content += " (full access granted to " + requestingUser + ")"; + } else { + content += " (limited access)"; + } + + return new ReadResourceResult(List.of( + new TextResourceContents("user-data://" + id, "text/plain", content) + )); +} +``` + +**Prompt callbacks:** +```java +@McpPrompt(name = "contextual-prompt", description = "Generate contextual prompt") +public GetPromptResult contextualPrompt( + @McpArg(name = "topic", required = true) String topic, + McpMeta meta) { + + String userPreference = (String) meta.get("preferredStyle"); + String language = (String) meta.get("language"); + + String message = "Let's discuss " + topic; + if ("formal".equals(userPreference)) { + message = "I would like to formally discuss the topic of " + topic; + } else if ("casual".equals(userPreference)) { + message = "Hey! Let's chat about " + topic; + } + + if (language != null && !"en".equals(language)) { + message += " (Note: Response requested in " + language + ")"; + } + + return new GetPromptResult("Contextual Prompt", + List.of(new PromptMessage(Role.ASSISTANT, new TextContent(message)))); +} +``` + +**Complete callbacks:** +```java +@McpComplete(prompt = "smart-complete") +public List smartComplete( + String prefix, + McpMeta meta) { + + String userLevel = (String) meta.get("userLevel"); + String domain = (String) meta.get("domain"); + + // Customize completions based on user context + List completions = generateBasicCompletions(prefix); + + if ("expert".equals(userLevel)) { + completions.addAll(generateAdvancedCompletions(prefix)); + } + + if (domain != null) { + completions = filterByDomain(completions, domain); + } + + return completions; +} +``` + +This feature enables context-aware MCP operations where the behavior can be customized based on client-provided metadata such as user identity, preferences, session information, or any other contextual data. + ### Async Tool Example ```java diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpMeta.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpMeta.java new file mode 100644 index 0000000..4091a55 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpMeta.java @@ -0,0 +1,30 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.annotation; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Special object used to represent the {@link McpSchema.Request#meta()}, + * {@link McpSchema.Notification#meta()} and {@link McpSchema.Result#meta()} values as + * method argument in all client and server MCP request and notification handlers. + * + * @author Christian Tzolov + */ +public record McpMeta(Map meta) { + + public McpMeta { + // Ensure idempotent initialization by creating an immutable copy + meta = meta == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(meta)); + } + + public Object get(String key) { + return meta.get(key); + } +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/complete/AbstractMcpCompleteMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/complete/AbstractMcpCompleteMethodCallback.java index c039234..e952992 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/complete/AbstractMcpCompleteMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/complete/AbstractMcpCompleteMethodCallback.java @@ -11,6 +11,7 @@ import org.springaicommunity.mcp.annotation.CompleteAdapter; import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import io.modelcontextprotocol.spec.McpSchema; @@ -127,20 +128,21 @@ protected void validateMethod(Method method) { protected void validateParameters(Method method) { Parameter[] parameters = method.getParameters(); - // Count non-progress-token parameters - int nonProgressTokenParamCount = 0; + // Count non-special parameters (excluding @McpProgressToken and McpMeta) + int nonSpecialParamCount = 0; for (Parameter param : parameters) { - if (!param.isAnnotationPresent(McpProgressToken.class)) { - nonProgressTokenParamCount++; + if (!param.isAnnotationPresent(McpProgressToken.class) + && !McpMeta.class.isAssignableFrom(param.getType())) { + nonSpecialParamCount++; } } - // Check parameter count - must have at most 3 non-progress-token parameters - if (nonProgressTokenParamCount > 3) { + // Check parameter count - must have at most 3 non-special parameters + if (nonSpecialParamCount > 3) { throw new IllegalArgumentException( - "Method can have at most 3 input parameters (excluding @McpProgressToken): " + method.getName() - + " in " + method.getDeclaringClass().getName() + " has " + nonProgressTokenParamCount - + " parameters"); + "Method can have at most 3 input parameters (excluding @McpProgressToken and McpMeta): " + + method.getName() + " in " + method.getDeclaringClass().getName() + " has " + + nonSpecialParamCount + " parameters"); } // Check parameter types @@ -148,6 +150,7 @@ protected void validateParameters(Method method) { boolean hasRequestParam = false; boolean hasArgumentParam = false; boolean hasProgressTokenParam = false; + boolean hasMetaParam = false; for (Parameter param : parameters) { Class paramType = param.getType(); @@ -162,6 +165,16 @@ protected void validateParameters(Method method) { continue; } + // Skip McpMeta parameters from validation + if (McpMeta.class.isAssignableFrom(paramType)) { + if (hasMetaParam) { + throw new IllegalArgumentException("Method cannot have more than one McpMeta parameter: " + + method.getName() + " in " + method.getDeclaringClass().getName()); + } + hasMetaParam = true; + continue; + } + if (isExchangeType(paramType)) { if (hasExchangeParam) { throw new IllegalArgumentException("Method cannot have more than one exchange parameter: " @@ -206,26 +219,22 @@ protected Object[] buildArgs(Method method, Object exchange, CompleteRequest req Parameter[] parameters = method.getParameters(); Object[] args = new Object[parameters.length]; - // First, handle @McpProgressToken annotated parameters for (int i = 0; i < parameters.length; i++) { - if (parameters[i].isAnnotationPresent(McpProgressToken.class)) { + Parameter param = parameters[i]; + Class paramType = param.getType(); + + // Handle @McpProgressToken annotated parameters + if (param.isAnnotationPresent(McpProgressToken.class)) { // CompleteRequest doesn't have a progressToken method in the current spec // Set to null for now - this would need to be updated when the spec // supports it args[i] = null; } - } - - for (int i = 0; i < parameters.length; i++) { - // Skip if already set (e.g., @McpProgressToken) - if (args[i] != null || parameters[i].isAnnotationPresent(McpProgressToken.class)) { - continue; + // Handle McpMeta parameters + else if (McpMeta.class.isAssignableFrom(paramType)) { + args[i] = request != null ? new McpMeta(request.meta()) : new McpMeta(null); } - - Parameter param = parameters[i]; - Class paramType = param.getType(); - - if (isExchangeType(paramType)) { + else if (isExchangeType(paramType)) { args[i] = exchange; } else if (CompleteRequest.class.isAssignableFrom(paramType)) { diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AbstractMcpPromptMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AbstractMcpPromptMethodCallback.java index 6df44b5..ac12e41 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AbstractMcpPromptMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AbstractMcpPromptMethodCallback.java @@ -10,6 +10,7 @@ import java.util.Map; import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; @@ -89,6 +90,7 @@ protected void validateParameters(Method method) { boolean hasRequestParam = false; boolean hasMapParam = false; boolean hasProgressTokenParam = false; + boolean hasMetaParam = false; for (java.lang.reflect.Parameter param : parameters) { Class paramType = param.getType(); @@ -103,6 +105,16 @@ protected void validateParameters(Method method) { continue; } + // Skip McpMeta parameters from validation + if (McpMeta.class.isAssignableFrom(paramType)) { + if (hasMetaParam) { + throw new IllegalArgumentException("Method cannot have more than one McpMeta parameter: " + + method.getName() + " in " + method.getDeclaringClass().getName()); + } + hasMetaParam = true; + continue; + } + if (isExchangeOrContextType(paramType)) { if (hasExchangeParam) { throw new IllegalArgumentException("Method cannot have more than one exchange parameter: " @@ -153,9 +165,17 @@ protected Object[] buildArgs(Method method, Object exchange, GetPromptRequest re } } + // Handle McpMeta parameters + for (int i = 0; i < parameters.length; i++) { + if (McpMeta.class.isAssignableFrom(parameters[i].getType())) { + args[i] = request != null ? new McpMeta(request.meta()) : new McpMeta(null); + } + } + for (int i = 0; i < parameters.length; i++) { - // Skip if already set (e.g., @McpProgressToken) - if (args[i] != null || parameters[i].isAnnotationPresent(McpProgressToken.class)) { + // Skip if already set (e.g., @McpProgressToken, McpMeta) + if (args[i] != null || parameters[i].isAnnotationPresent(McpProgressToken.class) + || McpMeta.class.isAssignableFrom(parameters[i].getType())) { continue; } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AbstractMcpResourceMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AbstractMcpResourceMethodCallback.java index d45427d..eb6645e 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AbstractMcpResourceMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AbstractMcpResourceMethodCallback.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import io.modelcontextprotocol.spec.McpSchema; @@ -144,26 +145,28 @@ protected void validateMethod(Method method) { protected void validateParametersWithoutUriVariables(Method method) { Parameter[] parameters = method.getParameters(); - // Count parameters excluding @McpProgressToken annotated ones - int nonProgressTokenParamCount = 0; + // Count parameters excluding @McpProgressToken and McpMeta annotated ones + int nonSpecialParamCount = 0; for (Parameter param : parameters) { - if (!param.isAnnotationPresent(McpProgressToken.class)) { - nonProgressTokenParamCount++; + if (!param.isAnnotationPresent(McpProgressToken.class) + && !McpMeta.class.isAssignableFrom(param.getType())) { + nonSpecialParamCount++; } } - // Check parameter count - must have at most 2 non-progress-token parameters - if (nonProgressTokenParamCount > 2) { + // Check parameter count - must have at most 2 non-special parameters + if (nonSpecialParamCount > 2) { throw new IllegalArgumentException( - "Method can have at most 2 input parameters (excluding @McpProgressToken) when no URI variables are present: " + "Method can have at most 2 input parameters (excluding @McpProgressToken and McpMeta) when no URI variables are present: " + method.getName() + " in " + method.getDeclaringClass().getName() + " has " - + nonProgressTokenParamCount + " non-progress-token parameters"); + + nonSpecialParamCount + " non-special parameters"); } // Check parameter types boolean hasValidParams = false; boolean hasExchangeParam = false; boolean hasRequestOrUriParam = false; + boolean hasMetaParam = false; for (Parameter param : parameters) { // Skip @McpProgressToken annotated parameters @@ -173,7 +176,14 @@ protected void validateParametersWithoutUriVariables(Method method) { Class paramType = param.getType(); - if (isExchangeOrContextType(paramType)) { + if (McpMeta.class.isAssignableFrom(paramType)) { + if (hasMetaParam) { + throw new IllegalArgumentException("Method cannot have more than one McpMeta parameter: " + + method.getName() + " in " + method.getDeclaringClass().getName()); + } + hasMetaParam = true; + } + else if (isExchangeOrContextType(paramType)) { if (hasExchangeParam) { throw new IllegalArgumentException("Method cannot have more than one exchange parameter: " + method.getName() + " in " + method.getDeclaringClass().getName()); @@ -192,13 +202,13 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType) } else { throw new IllegalArgumentException( - "Method parameters must be exchange, ReadResourceRequest, String, or @McpProgressToken when no URI variables are present: " + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken when no URI variables are present: " + method.getName() + " in " + method.getDeclaringClass().getName() + " has parameter of type " + paramType.getName()); } } - if (!hasValidParams && nonProgressTokenParamCount > 0) { + if (!hasValidParams && nonSpecialParamCount > 0) { throw new IllegalArgumentException( "Method must have either ReadResourceRequest or String parameter when no URI variables are present: " + method.getName() + " in " + method.getDeclaringClass().getName()); @@ -214,10 +224,11 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType) protected void validateParametersWithUriVariables(Method method) { Parameter[] parameters = method.getParameters(); - // Count special parameters (exchange, request, and progress token) + // Count special parameters (exchange, request, progress token, and meta) int exchangeParamCount = 0; int requestParamCount = 0; int progressTokenParamCount = 0; + int metaParamCount = 0; for (Parameter param : parameters) { if (param.isAnnotationPresent(McpProgressToken.class)) { @@ -225,7 +236,10 @@ protected void validateParametersWithUriVariables(Method method) { } else { Class paramType = param.getType(); - if (isExchangeOrContextType(paramType)) { + if (McpMeta.class.isAssignableFrom(paramType)) { + metaParamCount++; + } + else if (isExchangeOrContextType(paramType)) { exchangeParamCount++; } else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { @@ -246,8 +260,14 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { + method.getName() + " in " + method.getDeclaringClass().getName()); } + // Check if we have more than one meta parameter + if (metaParamCount > 1) { + throw new IllegalArgumentException("Method cannot have more than one McpMeta parameter: " + method.getName() + + " in " + method.getDeclaringClass().getName()); + } + // Calculate how many parameters should be for URI variables - int specialParamCount = exchangeParamCount + requestParamCount + progressTokenParamCount; + int specialParamCount = exchangeParamCount + requestParamCount + progressTokenParamCount + metaParamCount; int uriVarParamCount = parameters.length - specialParamCount; // Check if we have the right number of parameters for URI variables @@ -267,7 +287,7 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { Class paramType = param.getType(); if (!isExchangeOrContextType(paramType) && !ReadResourceRequest.class.isAssignableFrom(paramType) - && !String.class.isAssignableFrom(paramType)) { + && !McpMeta.class.isAssignableFrom(paramType) && !String.class.isAssignableFrom(paramType)) { throw new IllegalArgumentException("URI variable parameters must be of type String: " + method.getName() + " in " + method.getDeclaringClass().getName() + ", parameter of type " + paramType.getName() + " is not valid"); @@ -291,12 +311,16 @@ protected Object[] buildArgs(Method method, Object exchange, ReadResourceRequest Parameter[] parameters = method.getParameters(); Object[] args = new Object[parameters.length]; - // First, handle @McpProgressToken annotated parameters + // First, handle @McpProgressToken and McpMeta parameters for (int i = 0; i < parameters.length; i++) { if (parameters[i].isAnnotationPresent(McpProgressToken.class)) { // Get progress token from request args[i] = request != null ? request.progressToken() : null; } + else if (McpMeta.class.isAssignableFrom(parameters[i].getType())) { + // Inject McpMeta with request metadata + args[i] = request != null ? new McpMeta(request.meta()) : new McpMeta(null); + } } if (!this.uriVariables.isEmpty()) { @@ -325,10 +349,12 @@ protected void buildArgsWithUriVariables(Parameter[] parameters, Object[] args, List assignedVariables = new ArrayList<>(); // First pass: assign special parameters (exchange, request, and skip progress - // token) + // token and meta) for (int i = 0; i < parameters.length; i++) { - // Skip if parameter is annotated with @McpProgressToken (already handled) - if (parameters[i].isAnnotationPresent(McpProgressToken.class)) { + // Skip if parameter is annotated with @McpProgressToken or is McpMeta + // (already handled) + if (parameters[i].isAnnotationPresent(McpProgressToken.class) + || McpMeta.class.isAssignableFrom(parameters[i].getType())) { continue; } @@ -344,9 +370,11 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { // Second pass: assign URI variables to the remaining parameters int variableIndex = 0; for (int i = 0; i < parameters.length; i++) { - // Skip if parameter is annotated with @McpProgressToken (already handled) + // Skip if parameter is annotated with @McpProgressToken, is McpMeta (already + // handled) // or if it's already assigned (exchange or request) - if (parameters[i].isAnnotationPresent(McpProgressToken.class) || args[i] != null) { + if (parameters[i].isAnnotationPresent(McpProgressToken.class) + || McpMeta.class.isAssignableFrom(parameters[i].getType()) || args[i] != null) { continue; } @@ -377,8 +405,10 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { protected void buildArgsWithoutUriVariables(Parameter[] parameters, Object[] args, Object exchange, ReadResourceRequest request) { for (int i = 0; i < parameters.length; i++) { - // Skip if parameter is annotated with @McpProgressToken (already handled) - if (parameters[i].isAnnotationPresent(McpProgressToken.class)) { + // Skip if parameter is annotated with @McpProgressToken or is McpMeta + // (already handled) + if (parameters[i].isAnnotationPresent(McpProgressToken.class) + || McpMeta.class.isAssignableFrom(parameters[i].getType())) { continue; } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java index 692d28d..52639f7 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java @@ -23,6 +23,7 @@ import java.util.stream.Stream; import org.reactivestreams.Publisher; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.method.tool.utils.JsonParser; @@ -104,6 +105,12 @@ protected Object[] buildMethodArguments(T exchangeOrContext, Map return request != null ? request.progressToken() : null; } + // Check if parameter is McpMeta type + if (McpMeta.class.isAssignableFrom(parameter.getType())) { + // Return the meta from the request wrapped in McpMeta + return request != null ? new McpMeta(request.meta()) : new McpMeta(null); + } + // Check if parameter is CallToolRequest type if (CallToolRequest.class.isAssignableFrom(parameter.getType())) { return request; diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java index b208be1..a6f9c07 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java @@ -22,6 +22,7 @@ import java.util.Map; import java.util.stream.Stream; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.method.tool.utils.JsonParser; @@ -100,6 +101,12 @@ protected Object[] buildMethodArguments(T exchangeOrContext, Map return request != null ? request.progressToken() : null; } + // Check if parameter is McpMeta type + if (McpMeta.class.isAssignableFrom(parameter.getType())) { + // Return the meta from the request wrapped in McpMeta + return request != null ? new McpMeta(request.meta()) : new McpMeta(null); + } + // Check if parameter is CallToolRequest type if (CallToolRequest.class.isAssignableFrom(parameter.getType())) { return request; diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java index a911df1..5148298 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -104,13 +105,13 @@ private static String internalGenerateFromMethodArguments(Method method) { if (hasCallToolRequestParam) { // Check if there are other parameters besides CallToolRequest, exchange // types, - // and @McpProgressToken annotated parameters + // @McpProgressToken annotated parameters, and McpMeta parameters boolean hasOtherParams = Arrays.stream(method.getParameters()).anyMatch(param -> { Class type = param.getType(); return !CallToolRequest.class.isAssignableFrom(type) && !McpSyncServerExchange.class.isAssignableFrom(type) && !McpAsyncServerExchange.class.isAssignableFrom(type) - && !param.isAnnotationPresent(McpProgressToken.class); + && !param.isAnnotationPresent(McpProgressToken.class) && !McpMeta.class.isAssignableFrom(type); }); // If only CallToolRequest (and possibly exchange), return empty schema @@ -140,6 +141,11 @@ private static String internalGenerateFromMethodArguments(Method method) { continue; } + // Skip McpMeta parameters + if (parameterType instanceof Class parameterClass && McpMeta.class.isAssignableFrom(parameterClass)) { + continue; + } + // Skip special parameter types if (parameterType instanceof Class parameterClass && (ClassUtils.isAssignable(McpSyncServerExchange.class, parameterClass) diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncMcpCompleteMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncMcpCompleteMethodCallbackTests.java index 0bb4ca8..b4b3308 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncMcpCompleteMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncMcpCompleteMethodCallbackTests.java @@ -16,7 +16,8 @@ import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpComplete; -import org.springaicommunity.mcp.method.complete.AsyncMcpCompleteMethodCallback; +import org.springaicommunity.mcp.annotation.McpMeta; +import org.springaicommunity.mcp.annotation.McpProgressToken; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -132,6 +133,46 @@ public Mono duplicateArgumentParameters(CompleteRequest.Complete return Mono.just(new CompleteResult(new CompleteCompletion(List.of(), 0, false))); } + public Mono getCompletionWithProgressToken(@McpProgressToken String progressToken, + CompleteRequest request) { + String tokenInfo = progressToken != null ? " (token: " + progressToken + ")" : " (no token)"; + return Mono.just(new CompleteResult(new CompleteCompletion( + List.of("Async completion with progress" + tokenInfo + " for: " + request.argument().value()), 1, + false))); + } + + public Mono getCompletionWithMixedAndProgress(McpAsyncServerExchange exchange, + @McpProgressToken String progressToken, String value, CompleteRequest request) { + String tokenInfo = progressToken != null ? " (token: " + progressToken + ")" : " (no token)"; + return Mono.just(new CompleteResult(new CompleteCompletion(List.of("Async mixed completion" + tokenInfo + + " with value: " + value + " and request: " + request.argument().value()), 1, false))); + } + + public Mono duplicateProgressTokenParameters(@McpProgressToken String token1, + @McpProgressToken String token2) { + return Mono.just(new CompleteResult(new CompleteCompletion(List.of(), 0, false))); + } + + public Mono getCompletionWithMeta(McpMeta meta, CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return Mono.just(new CompleteResult(new CompleteCompletion( + List.of("Async completion with meta" + metaInfo + " for: " + request.argument().value()), 1, + false))); + } + + public Mono getCompletionWithMetaAndMixed(McpAsyncServerExchange exchange, McpMeta meta, + String value, CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return Mono.just(new CompleteResult(new CompleteCompletion(List.of("Async mixed completion" + metaInfo + + " with value: " + value + " and request: " + request.argument().value()), 1, false))); + } + + public Mono duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return Mono.just(new CompleteResult(new CompleteCompletion(List.of(), 0, false))); + } + } // Helper method to create a mock McpComplete annotation @@ -638,4 +679,174 @@ public void testNullRequest() throws Exception { .verify(); } + @Test + public void testCallbackWithProgressToken() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("getCompletionWithProgressToken", String.class, + CompleteRequest.class); + + BiFunction> callback = AsyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + // Since CompleteRequest doesn't have progressToken, it should be null + assertThat(result.completion().values().get(0)) + .isEqualTo("Async completion with progress (no token) for: value"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMixedAndProgressToken() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("getCompletionWithMixedAndProgress", + McpAsyncServerExchange.class, String.class, String.class, CompleteRequest.class); + + BiFunction> callback = AsyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + // Since CompleteRequest doesn't have progressToken, it should be null + assertThat(result.completion().values().get(0)) + .isEqualTo("Async mixed completion (no token) with value: value and request: value"); + }).verifyComplete(); + } + + @Test + public void testDuplicateProgressTokenParameters() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("duplicateProgressTokenParameters", String.class, + String.class); + + assertThatThrownBy(() -> AsyncMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one @McpProgressToken parameter"); + } + + @Test + public void testCallbackWithMeta() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction> callback = AsyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Async completion with meta (meta: test-value) for: value"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction> callback = AsyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Async completion with meta (no meta) for: value"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAndMixed() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("getCompletionWithMetaAndMixed", + McpAsyncServerExchange.class, McpMeta.class, String.class, CompleteRequest.class); + + BiFunction> callback = AsyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Async mixed completion (meta: test-value) with value: value and request: value"); + }).verifyComplete(); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestAsyncCompleteProvider provider = new TestAsyncCompleteProvider(); + Method method = TestAsyncCompleteProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, + McpMeta.class); + + assertThatThrownBy(() -> AsyncMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncStatelessMcpCompleteMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncStatelessMcpCompleteMethodCallbackTests.java index 9834187..dd654fe 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncStatelessMcpCompleteMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/AsyncStatelessMcpCompleteMethodCallbackTests.java @@ -16,6 +16,8 @@ import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpMeta; +import org.springaicommunity.mcp.annotation.McpProgressToken; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -130,6 +132,46 @@ public Mono duplicateArgumentParameters(CompleteRequest.Complete return Mono.just(new CompleteResult(new CompleteCompletion(List.of(), 0, false))); } + public Mono getCompletionWithProgressToken(@McpProgressToken String progressToken, + CompleteRequest request) { + String tokenInfo = progressToken != null ? " (token: " + progressToken + ")" : " (no token)"; + return Mono.just(new CompleteResult(new CompleteCompletion(List + .of("Async stateless completion with progress" + tokenInfo + " for: " + request.argument().value()), 1, + false))); + } + + public Mono getCompletionWithMixedAndProgress(McpTransportContext context, + @McpProgressToken String progressToken, String value, CompleteRequest request) { + String tokenInfo = progressToken != null ? " (token: " + progressToken + ")" : " (no token)"; + return Mono.just(new CompleteResult(new CompleteCompletion(List.of("Async stateless mixed completion" + + tokenInfo + " with value: " + value + " and request: " + request.argument().value()), 1, false))); + } + + public Mono duplicateProgressTokenParameters(@McpProgressToken String token1, + @McpProgressToken String token2) { + return Mono.just(new CompleteResult(new CompleteCompletion(List.of(), 0, false))); + } + + public Mono getCompletionWithMeta(McpMeta meta, CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return Mono.just(new CompleteResult(new CompleteCompletion( + List.of("Async stateless completion with meta" + metaInfo + " for: " + request.argument().value()), + 1, false))); + } + + public Mono getCompletionWithMetaAndMixed(McpTransportContext context, McpMeta meta, + String value, CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return Mono.just(new CompleteResult(new CompleteCompletion(List.of("Async stateless mixed completion" + + metaInfo + " with value: " + value + " and request: " + request.argument().value()), 1, false))); + } + + public Mono duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return Mono.just(new CompleteResult(new CompleteCompletion(List.of(), 0, false))); + } + } @Test @@ -632,4 +674,174 @@ public void testNullRequest() throws Exception { .verify(); } + @Test + public void testCallbackWithProgressToken() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("getCompletionWithProgressToken", + String.class, CompleteRequest.class); + + BiFunction> callback = AsyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + // Since CompleteRequest doesn't have progressToken, it should be null + assertThat(result.completion().values().get(0)) + .isEqualTo("Async stateless completion with progress (no token) for: value"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMixedAndProgressToken() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("getCompletionWithMixedAndProgress", + McpTransportContext.class, String.class, String.class, CompleteRequest.class); + + BiFunction> callback = AsyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + // Since CompleteRequest doesn't have progressToken, it should be null + assertThat(result.completion().values().get(0)) + .isEqualTo("Async stateless mixed completion (no token) with value: value and request: value"); + }).verifyComplete(); + } + + @Test + public void testDuplicateProgressTokenParameters() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("duplicateProgressTokenParameters", + String.class, String.class); + + assertThatThrownBy(() -> AsyncStatelessMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one @McpProgressToken parameter"); + } + + @Test + public void testCallbackWithMeta() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction> callback = AsyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Async stateless completion with meta (meta: test-value) for: value"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction> callback = AsyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Async stateless completion with meta (no meta) for: value"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAndMixed() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("getCompletionWithMetaAndMixed", + McpTransportContext.class, McpMeta.class, String.class, CompleteRequest.class); + + BiFunction> callback = AsyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Async stateless mixed completion (meta: test-value) with value: value and request: value"); + }).verifyComplete(); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestAsyncStatelessCompleteProvider provider = new TestAsyncStatelessCompleteProvider(); + Method method = TestAsyncStatelessCompleteProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, + McpMeta.class); + + assertThatThrownBy(() -> AsyncStatelessMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncMcpCompleteMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncMcpCompleteMethodCallbackTests.java index 301c504..73a3817 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncMcpCompleteMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncMcpCompleteMethodCallbackTests.java @@ -16,6 +16,7 @@ import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import static org.assertj.core.api.Assertions.assertThat; @@ -125,6 +126,25 @@ public CompleteResult duplicateProgressTokenParameters(@McpProgressToken String return new CompleteResult(new CompleteCompletion(List.of(), 0, false)); } + public CompleteResult getCompletionWithMeta(McpMeta meta, CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return new CompleteResult(new CompleteCompletion( + List.of("Completion with meta" + metaInfo + " for: " + request.argument().value()), 1, false)); + } + + public CompleteResult getCompletionWithMetaAndMixed(McpSyncServerExchange exchange, McpMeta meta, String value, + CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return new CompleteResult(new CompleteCompletion(List.of("Mixed completion" + metaInfo + " with value: " + + value + " and request: " + request.argument().value()), 1, false)); + } + + public CompleteResult duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return new CompleteResult(new CompleteCompletion(List.of(), 0, false)); + } + } // Helper method to create a mock McpComplete annotation @@ -573,4 +593,93 @@ public void testDuplicateProgressTokenParameters() throws Exception { .hasMessageContaining("Method cannot have more than one @McpProgressToken parameter"); } + @Test + public void testCallbackWithMeta() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction callback = SyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + CompleteResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)).isEqualTo("Completion with meta (meta: test-value) for: value"); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction callback = SyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + CompleteResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)).isEqualTo("Completion with meta (no meta) for: value"); + } + + @Test + public void testCallbackWithMetaAndMixed() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMetaAndMixed", + McpSyncServerExchange.class, McpMeta.class, String.class, CompleteRequest.class); + + BiFunction callback = SyncMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + CompleteResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Mixed completion (meta: test-value) with value: value and request: value"); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, McpMeta.class); + + assertThatThrownBy(() -> SyncMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncStatelessMcpCompleteMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncStatelessMcpCompleteMethodCallbackTests.java index f62b103..ace796f 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncStatelessMcpCompleteMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/complete/SyncStatelessMcpCompleteMethodCallbackTests.java @@ -16,6 +16,8 @@ import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpMeta; +import org.springaicommunity.mcp.annotation.McpProgressToken; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -104,6 +106,44 @@ public CompleteResult duplicateArgumentParameters(CompleteRequest.CompleteArgume return new CompleteResult(new CompleteCompletion(List.of(), 0, false)); } + public CompleteResult getCompletionWithProgressToken(@McpProgressToken String progressToken, + CompleteRequest request) { + String tokenInfo = progressToken != null ? " (token: " + progressToken + ")" : " (no token)"; + return new CompleteResult(new CompleteCompletion( + List.of("Completion with progress" + tokenInfo + " for: " + request.argument().value()), 1, false)); + } + + public CompleteResult getCompletionWithMixedAndProgress(McpTransportContext context, + @McpProgressToken String progressToken, String value, CompleteRequest request) { + String tokenInfo = progressToken != null ? " (token: " + progressToken + ")" : " (no token)"; + return new CompleteResult(new CompleteCompletion(List.of("Mixed completion" + tokenInfo + " with value: " + + value + " and request: " + request.argument().value()), 1, false)); + } + + public CompleteResult duplicateProgressTokenParameters(@McpProgressToken String token1, + @McpProgressToken String token2) { + return new CompleteResult(new CompleteCompletion(List.of(), 0, false)); + } + + public CompleteResult getCompletionWithMeta(McpMeta meta, CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return new CompleteResult(new CompleteCompletion( + List.of("Completion with meta" + metaInfo + " for: " + request.argument().value()), 1, false)); + } + + public CompleteResult getCompletionWithMetaAndMixed(McpTransportContext context, McpMeta meta, String value, + CompleteRequest request) { + String metaInfo = meta != null && meta.get("key") != null ? " (meta: " + meta.get("key") + ")" + : " (no meta)"; + return new CompleteResult(new CompleteCompletion(List.of("Mixed completion" + metaInfo + " with value: " + + value + " and request: " + request.argument().value()), 1, false)); + } + + public CompleteResult duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return new CompleteResult(new CompleteCompletion(List.of(), 0, false)); + } + } @Test @@ -465,4 +505,160 @@ public void testNullRequest() throws Exception { .hasMessageContaining("Request must not be null"); } + @Test + public void testCallbackWithProgressToken() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithProgressToken", String.class, + CompleteRequest.class); + + BiFunction callback = SyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + CompleteResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + // Since CompleteRequest doesn't have progressToken, it should be null + assertThat(result.completion().values().get(0)).isEqualTo("Completion with progress (no token) for: value"); + } + + @Test + public void testCallbackWithMixedAndProgressToken() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMixedAndProgress", + McpTransportContext.class, String.class, String.class, CompleteRequest.class); + + BiFunction callback = SyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + CompleteResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + // Since CompleteRequest doesn't have progressToken, it should be null + assertThat(result.completion().values().get(0)) + .isEqualTo("Mixed completion (no token) with value: value and request: value"); + } + + @Test + public void testDuplicateProgressTokenParameters() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("duplicateProgressTokenParameters", String.class, + String.class); + + assertThatThrownBy(() -> SyncStatelessMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one @McpProgressToken parameter"); + } + + @Test + public void testCallbackWithMeta() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction callback = SyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + CompleteResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)).isEqualTo("Completion with meta (meta: test-value) for: value"); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMeta", McpMeta.class, + CompleteRequest.class); + + BiFunction callback = SyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value")); + + CompleteResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)).isEqualTo("Completion with meta (no meta) for: value"); + } + + @Test + public void testCallbackWithMetaAndMixed() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("getCompletionWithMetaAndMixed", McpTransportContext.class, + McpMeta.class, String.class, CompleteRequest.class); + + BiFunction callback = SyncStatelessMcpCompleteMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + CompleteRequest request = new CompleteRequest(new PromptReference("test-prompt"), + new CompleteRequest.CompleteArgument("test", "value"), java.util.Map.of("key", "test-value")); + + CompleteResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.completion()).isNotNull(); + assertThat(result.completion().values()).hasSize(1); + assertThat(result.completion().values().get(0)) + .isEqualTo("Mixed completion (meta: test-value) with value: value and request: value"); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestCompleteProvider provider = new TestCompleteProvider(); + Method method = TestCompleteProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, McpMeta.class); + + assertThatThrownBy(() -> SyncStatelessMcpCompleteMethodCallback.builder() + .method(method) + .bean(provider) + .prompt("test-prompt") + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallbackTests.java index 2e3c39f..589c24b 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallbackTests.java @@ -19,8 +19,9 @@ import io.modelcontextprotocol.spec.McpSchema.Role; import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpPrompt; -import org.springaicommunity.mcp.method.prompt.AsyncMcpPromptMethodCallback; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -138,6 +139,28 @@ public GetPromptResult duplicateMapParameters(Map args1, Map getMonoPromptWithMeta( + @McpArg(name = "name", description = "The user's name", required = true) String name, McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return Mono.just(new GetPromptResult("Mono meta prompt", List + .of(new PromptMessage(Role.ASSISTANT, new TextContent("Hello " + name + ", Meta: " + metaInfo))))); + } + + @McpPrompt(name = "mono-mixed-with-meta", description = "A prompt with mixed args and meta") + public Mono getMonoPromptWithMixedAndMeta(McpAsyncServerExchange exchange, + @McpArg(name = "name", description = "The user's name", required = true) String name, McpMeta meta, + GetPromptRequest request) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return Mono + .just(new GetPromptResult("Mono mixed with meta prompt", List.of(new PromptMessage(Role.ASSISTANT, + new TextContent("Hello " + name + " from " + request.name() + ", Meta: " + metaInfo))))); + } + + public Mono duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return Mono.just(new GetPromptResult("Invalid", List.of())); + } + } private Prompt createTestPrompt(String name, String description) { @@ -337,4 +360,120 @@ public void testNullRequest() throws Exception { StepVerifier.create(callback.apply(exchange, null)).expectErrorMessage("Request must not be null").verify(); } + @Test + public void testCallbackWithMonoMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getMonoPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("mono-meta-prompt", "A prompt with meta parameter"); + + BiFunction> callback = AsyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("mono-meta-prompt", args, + Map.of("userId", "user123", "sessionId", "session456")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Mono meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .contains("Hello John, Meta: {userId=user123, sessionId=session456}"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMonoMetaNull() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getMonoPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("mono-meta-prompt", "A prompt with meta parameter"); + + BiFunction> callback = AsyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request without meta + GetPromptRequest request = new GetPromptRequest("mono-meta-prompt", args); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Mono meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()).isEqualTo("Hello John, Meta: {}"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMonoMixedAndMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getMonoPromptWithMixedAndMeta", + McpAsyncServerExchange.class, String.class, McpMeta.class, GetPromptRequest.class); + + Prompt prompt = createTestPrompt("mono-mixed-with-meta", "A prompt with mixed args and meta"); + + BiFunction> callback = AsyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("mono-mixed-with-meta", args, Map.of("userId", "user123")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Mono mixed with meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .isEqualTo("Hello John from mono-mixed-with-meta, Meta: {userId=user123}"); + }).verifyComplete(); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, McpMeta.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameters"); + + assertThatThrownBy( + () -> AsyncMcpPromptMethodCallback.builder().method(method).bean(provider).prompt(prompt).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallbackTests.java index 6e4b983..b82b3b8 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallbackTests.java @@ -20,6 +20,7 @@ import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpPrompt; import reactor.core.publisher.Mono; @@ -141,6 +142,28 @@ public GetPromptResult duplicateMapParameters(Map args1, Map getMonoPromptWithMeta( + @McpArg(name = "name", description = "The user's name", required = true) String name, McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return Mono.just(new GetPromptResult("Async stateless meta prompt", List + .of(new PromptMessage(Role.ASSISTANT, new TextContent("Hello " + name + ", Meta: " + metaInfo))))); + } + + @McpPrompt(name = "async-stateless-mixed-with-meta", description = "A prompt with mixed args and meta") + public Mono getMonoPromptWithMixedAndMeta(McpTransportContext context, + @McpArg(name = "name", description = "The user's name", required = true) String name, McpMeta meta, + GetPromptRequest request) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return Mono.just(new GetPromptResult("Async stateless mixed with meta prompt", + List.of(new PromptMessage(Role.ASSISTANT, + new TextContent("Hello " + name + " from " + request.name() + ", Meta: " + metaInfo))))); + } + + public Mono duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return Mono.just(new GetPromptResult("Invalid", List.of())); + } + } private Prompt createTestPrompt(String name, String description) { @@ -684,4 +707,123 @@ public void testNullRequest() throws Exception { StepVerifier.create(callback.apply(context, null)).expectErrorMessage("Request must not be null").verify(); } + @Test + public void testCallbackWithAsyncStatelessMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getMonoPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("async-stateless-meta-prompt", "A prompt with meta parameter"); + + BiFunction> callback = AsyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("async-stateless-meta-prompt", args, + Map.of("userId", "user123", "sessionId", "session456")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Async stateless meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .contains("Hello John, Meta: {userId=user123, sessionId=session456}"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithAsyncStatelessMetaNull() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getMonoPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("async-stateless-meta-prompt", "A prompt with meta parameter"); + + BiFunction> callback = AsyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request without meta + GetPromptRequest request = new GetPromptRequest("async-stateless-meta-prompt", args); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Async stateless meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()).isEqualTo("Hello John, Meta: {}"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithAsyncStatelessMixedAndMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getMonoPromptWithMixedAndMeta", McpTransportContext.class, + String.class, McpMeta.class, GetPromptRequest.class); + + Prompt prompt = createTestPrompt("async-stateless-mixed-with-meta", "A prompt with mixed args and meta"); + + BiFunction> callback = AsyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("async-stateless-mixed-with-meta", args, + Map.of("userId", "user123")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Async stateless mixed with meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .isEqualTo("Hello John from async-stateless-mixed-with-meta, Meta: {userId=user123}"); + }).verifyComplete(); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, McpMeta.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameters"); + + assertThatThrownBy(() -> AsyncStatelessMcpPromptMethodCallback.builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallbackTests.java index 129ea39..16f80c8 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallbackTests.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpPrompt; @@ -134,6 +135,27 @@ public GetPromptResult duplicateProgressTokenParameters(@McpProgressToken String return new GetPromptResult("Invalid", List.of()); } + @McpPrompt(name = "meta-prompt", description = "A prompt with meta parameter") + public GetPromptResult getPromptWithMeta( + @McpArg(name = "name", description = "The user's name", required = true) String name, McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return new GetPromptResult("Meta prompt", List + .of(new PromptMessage(Role.ASSISTANT, new TextContent("Hello " + name + ", Meta: " + metaInfo)))); + } + + @McpPrompt(name = "mixed-with-meta", description = "A prompt with mixed args and meta") + public GetPromptResult getPromptWithMixedAndMeta(McpSyncServerExchange exchange, + @McpArg(name = "name", description = "The user's name", required = true) String name, McpMeta meta, + GetPromptRequest request) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return new GetPromptResult("Mixed with meta prompt", List.of(new PromptMessage(Role.ASSISTANT, + new TextContent("Hello " + name + " from " + request.name() + ", Meta: " + metaInfo)))); + } + + public GetPromptResult duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { + return new GetPromptResult("Invalid", List.of()); + } + } private Prompt createTestPrompt(String name, String description) { @@ -570,4 +592,114 @@ public void testDuplicateProgressTokenParameters() throws Exception { .hasMessageContaining("Method cannot have more than one @McpProgressToken parameter"); } + @Test + public void testCallbackWithMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("meta-prompt", "A prompt with meta parameter"); + + BiFunction callback = SyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("meta-prompt", args, + Map.of("userId", "user123", "sessionId", "session456")); + + GetPromptResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .contains("Hello John, Meta: {userId=user123, sessionId=session456}"); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("meta-prompt", "A prompt with meta parameter"); + + BiFunction callback = SyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request without meta + GetPromptRequest request = new GetPromptRequest("meta-prompt", args); + + GetPromptResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()).isEqualTo("Hello John, Meta: {}"); + } + + @Test + public void testCallbackWithMixedAndMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getPromptWithMixedAndMeta", McpSyncServerExchange.class, + String.class, McpMeta.class, GetPromptRequest.class); + + Prompt prompt = createTestPrompt("mixed-with-meta", "A prompt with mixed args and meta"); + + BiFunction callback = SyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("mixed-with-meta", args, Map.of("userId", "user123")); + + GetPromptResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Mixed with meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .isEqualTo("Hello John from mixed-with-meta, Meta: {userId=user123}"); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, McpMeta.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameters"); + + assertThatThrownBy( + () -> SyncMcpPromptMethodCallback.builder().method(method).bean(provider).prompt(prompt).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallbackTests.java index 7a17c0c..574efba 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallbackTests.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpPrompt; import io.modelcontextprotocol.server.McpTransportContext; @@ -109,6 +110,27 @@ public GetPromptResult duplicateMapParameters(Map args1, Map callback = SyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("stateless-meta-prompt", args, + Map.of("userId", "user123", "sessionId", "session456")); + + GetPromptResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Stateless meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .contains("Hello John, Meta: {userId=user123, sessionId=session456}"); + } + + @Test + public void testCallbackWithStatelessMetaNull() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getPromptWithMeta", String.class, McpMeta.class); + + Prompt prompt = createTestPrompt("stateless-meta-prompt", "A prompt with meta parameter"); + + BiFunction callback = SyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request without meta + GetPromptRequest request = new GetPromptRequest("stateless-meta-prompt", args); + + GetPromptResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Stateless meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()).isEqualTo("Hello John, Meta: {}"); + } + + @Test + public void testCallbackWithStatelessMixedAndMeta() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getPromptWithMixedAndMeta", McpTransportContext.class, + String.class, McpMeta.class, GetPromptRequest.class); + + Prompt prompt = createTestPrompt("stateless-mixed-with-meta", "A prompt with mixed args and meta"); + + BiFunction callback = SyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + + // Create request with meta data + GetPromptRequest request = new GetPromptRequest("stateless-mixed-with-meta", args, Map.of("userId", "user123")); + + GetPromptResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Stateless mixed with meta prompt"); + assertThat(result.messages()).hasSize(1); + PromptMessage message = result.messages().get(0); + assertThat(message.role()).isEqualTo(Role.ASSISTANT); + assertThat(((TextContent) message.content()).text()) + .isEqualTo("Hello John from stateless-mixed-with-meta, Meta: {userId=user123}"); + } + + @Test + public void testDuplicateMetaParameters() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("duplicateMetaParameters", McpMeta.class, McpMeta.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameters"); + + assertThatThrownBy(() -> SyncStatelessMcpPromptMethodCallback.builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } 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 4163bef..90daac6 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 @@ -18,6 +18,7 @@ import io.modelcontextprotocol.util.McpUriTemplateManager; import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.ResourceAdaptor; @@ -181,6 +182,57 @@ public Mono duplicateRequestParameters(ReadResourceRequest r return Mono.just(new ReadResourceResult(List.of())); } + // Methods for testing @McpMeta + public ReadResourceResult getResourceWithMeta(McpMeta meta, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with meta: " + metaValue + " for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public Mono getResourceWithMetaAsync(McpMeta meta, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Async content with meta: " + metaValue + " for " + request.uri(); + return Mono + .just(new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content)))); + } + + public ReadResourceResult getResourceWithMetaOnly(McpMeta meta) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with only meta: " + metaValue; + return new ReadResourceResult(List.of(new TextResourceContents("test://resource", "text/plain", content))); + } + + @McpResource(uri = "users/{userId}/posts/{postId}") + public ReadResourceResult getResourceWithMetaAndUriVariables(McpMeta meta, String userId, String postId) { + String metaValue = (String) meta.get("testKey"); + String content = "User: " + userId + ", Post: " + postId + ", Meta: " + metaValue; + return new ReadResourceResult( + List.of(new TextResourceContents("users/" + userId + "/posts/" + postId, "text/plain", content))); + } + + public Mono getResourceWithExchangeAndMeta(McpAsyncServerExchange exchange, McpMeta meta, + ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Async content with exchange and meta: " + metaValue + " for " + request.uri(); + return Mono + .just(new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content)))); + } + + public ReadResourceResult getResourceWithMetaAndMixedParams(McpMeta meta, + @McpProgressToken String progressToken, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with meta: " + metaValue + " and progress: " + progressToken + " for " + + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta meta2, + ReadResourceRequest request) { + // This should cause a validation error during callback creation + String content = "Content with multiple metas for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + } // Helper method to create a mock McpResource annotation @@ -822,4 +874,227 @@ public void testCallbackWithMultipleProgressTokens() throws Exception { }).verifyComplete(); } + // Tests for @McpMeta functionality + @Test + public void testCallbackWithMeta() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "testValue")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: testValue for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAsync() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMetaAsync", McpMeta.class, + ReadResourceRequest.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "asyncValue")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Async content with meta: asyncValue for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(null); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: null for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaOnly() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMetaOnly", McpMeta.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "metaOnlyValue")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with only meta: metaOnlyValue"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAndUriVariables() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMetaAndUriVariables", McpMeta.class, + String.class, String.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(resourceAnnotation)) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("users/123/posts/456"); + when(request.meta()).thenReturn(Map.of("testKey", "uriMetaValue")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("User: 123, Post: 456, Meta: uriMetaValue"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithExchangeAndMeta() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithExchangeAndMeta", + McpAsyncServerExchange.class, McpMeta.class, ReadResourceRequest.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "exchangeMetaValue")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()) + .isEqualTo("Async content with exchange and meta: exchangeMetaValue for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAndMixedParams() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMetaAndMixedParams", McpMeta.class, + String.class, ReadResourceRequest.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "mixedMetaValue")); + when(request.progressToken()).thenReturn("mixedProgress"); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()) + .isEqualTo("Content with meta: mixedMetaValue and progress: mixedProgress for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMultipleMetas() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getResourceWithMultipleMetas", McpMeta.class, + McpMeta.class, ReadResourceRequest.class); + + // This should throw an exception during callback creation due to multiple + // McpMeta parameters + assertThatThrownBy(() -> AsyncMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } 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 97edddd..06ebadf 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 @@ -18,6 +18,8 @@ import io.modelcontextprotocol.util.McpUriTemplateManager; import io.modelcontextprotocol.util.McpUriTemplateManagerFactory; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpMeta; +import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.ResourceAdaptor; @@ -27,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Tests for {@link AsyncStatelessMcpResourceMethodCallback}. @@ -134,6 +137,57 @@ public Mono duplicateRequestParameters(ReadResourceRequest r return Mono.just(new ReadResourceResult(List.of())); } + // Methods for testing @McpMeta + public ReadResourceResult getResourceWithMeta(McpMeta meta, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with meta: " + metaValue + " for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public Mono getResourceWithMetaAsync(McpMeta meta, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Async content with meta: " + metaValue + " for " + request.uri(); + return Mono + .just(new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content)))); + } + + public ReadResourceResult getResourceWithMetaOnly(McpMeta meta) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with only meta: " + metaValue; + return new ReadResourceResult(List.of(new TextResourceContents("test://resource", "text/plain", content))); + } + + @McpResource(uri = "users/{userId}/posts/{postId}") + public ReadResourceResult getResourceWithMetaAndUriVariables(McpMeta meta, String userId, String postId) { + String metaValue = (String) meta.get("testKey"); + String content = "User: " + userId + ", Post: " + postId + ", Meta: " + metaValue; + return new ReadResourceResult( + List.of(new TextResourceContents("users/" + userId + "/posts/" + postId, "text/plain", content))); + } + + public Mono getResourceWithContextAndMeta(McpTransportContext context, McpMeta meta, + ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Async content with context and meta: " + metaValue + " for " + request.uri(); + return Mono + .just(new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content)))); + } + + public ReadResourceResult getResourceWithMetaAndMixedParams(McpMeta meta, + @McpProgressToken String progressToken, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with meta: " + metaValue + " and progress: " + progressToken + " for " + + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta meta2, + ReadResourceRequest request) { + // This should cause a validation error during callback creation + String content = "Content with multiple metas for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + } // Helper method to create a mock McpResource annotation @@ -643,4 +697,214 @@ public void testUriVariableExtraction() throws Exception { .verify(); } + @Test + public void testCallbackWithMeta() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("test/resource", Map.of("testKey", "testValue")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: testValue for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAsync() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMetaAsync", McpMeta.class, + ReadResourceRequest.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("test/resource", Map.of("testKey", "asyncValue")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Async content with meta: asyncValue for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("test/resource", null); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: null for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaOnly() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMetaOnly", McpMeta.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("test/resource", Map.of("testKey", "onlyMetaValue")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with only meta: onlyMetaValue"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAndUriVariables() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMetaAndUriVariables", + McpMeta.class, String.class, String.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(resourceAnnotation)) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("users/123/posts/456", Map.of("testKey", "uriMetaValue")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("User: 123, Post: 456, Meta: uriMetaValue"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithContextAndMeta() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithContextAndMeta", + McpTransportContext.class, McpMeta.class, ReadResourceRequest.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("test/resource", Map.of("testKey", "contextMetaValue")); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()) + .isEqualTo("Async content with context and meta: contextMetaValue for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMetaAndMixedParams() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMetaAndMixedParams", + McpMeta.class, String.class, ReadResourceRequest.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "mixedValue")); + when(request.progressToken()).thenReturn("progress123"); + + Mono resultMono = callback.apply(context, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()) + .isEqualTo("Content with meta: mixedValue and progress: progress123 for test/resource"); + }).verifyComplete(); + } + + @Test + public void testCallbackWithMultipleMetas() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getResourceWithMultipleMetas", + McpMeta.class, McpMeta.class, ReadResourceRequest.class); + + // This should throw an exception during callback creation due to multiple McpMeta + // parameters + assertThatThrownBy(() -> AsyncStatelessMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } 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 1641bbf..e2a49fe 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 @@ -14,6 +14,7 @@ import java.util.function.BiFunction; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.ResourceAdaptor; @@ -80,6 +81,43 @@ public ReadResourceResult getResourceWithProgressTokenAndMixedParams(@McpProgres return new ReadResourceResult(List.of(new TextResourceContents("users/" + userId, "text/plain", content))); } + // Methods for testing McpMeta + public ReadResourceResult getResourceWithMeta(McpMeta meta, ReadResourceRequest request) { + String content = "Content with meta: " + meta.get("key") + " for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public ReadResourceResult getResourceWithMetaOnly(McpMeta meta) { + String content = "Content with only meta: " + meta.get("key"); + return new ReadResourceResult(List.of(new TextResourceContents("test://resource", "text/plain", content))); + } + + @McpResource(uri = "users/{userId}/posts/{postId}") + public ReadResourceResult getResourceWithMetaAndUriVariables(McpMeta meta, String userId, String postId) { + String content = "User: " + userId + ", Post: " + postId + ", Meta: " + meta.get("key"); + return new ReadResourceResult( + List.of(new TextResourceContents("users/" + userId + "/posts/" + postId, "text/plain", content))); + } + + public ReadResourceResult getResourceWithExchangeAndMeta(McpSyncServerExchange exchange, McpMeta meta, + ReadResourceRequest request) { + String content = "Content with exchange and meta: " + meta.get("key") + " for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + @McpResource(uri = "users/{userId}") + public ReadResourceResult getResourceWithMetaAndMixedParams(McpMeta meta, String userId) { + String content = "User: " + userId + ", Meta: " + meta.get("key"); + return new ReadResourceResult(List.of(new TextResourceContents("users/" + userId, "text/plain", content))); + } + + public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta meta2, + ReadResourceRequest request) { + // This should cause a validation error + String content = "Content with multiple metas"; + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + public ReadResourceResult getResourceWithExchange(McpSyncServerExchange exchange, ReadResourceRequest request) { return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", "Content with exchange for " + request.uri()))); @@ -847,4 +885,184 @@ public void testCallbackWithProgressTokenAndMixedParams() throws Exception { assertThat(textContent.text()).isEqualTo("User: john, Progress: progress-xyz"); } + // Tests for McpMeta functionality + @Test + public void testCallbackWithMeta() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(java.util.Map.of("key", "meta-value-123")); + + ReadResourceResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: meta-value-123 for test/resource"); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(null); + + ReadResourceResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: null for test/resource"); + } + + @Test + public void testCallbackWithMetaOnly() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMetaOnly", McpMeta.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(java.util.Map.of("key", "meta-value-456")); + + ReadResourceResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with only meta: meta-value-456"); + } + + @Test + public void testCallbackWithMetaAndUriVariables() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMetaAndUriVariables", McpMeta.class, + String.class, String.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(resourceAnnotation)) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("users/123/posts/456"); + when(request.meta()).thenReturn(java.util.Map.of("key", "meta-value-789")); + + ReadResourceResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("User: 123, Post: 456, Meta: meta-value-789"); + } + + @Test + public void testCallbackWithExchangeAndMeta() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithExchangeAndMeta", + McpSyncServerExchange.class, McpMeta.class, ReadResourceRequest.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(java.util.Map.of("key", "meta-value-abc")); + + ReadResourceResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with exchange and meta: meta-value-abc for test/resource"); + } + + @Test + public void testCallbackWithMetaAndMixedParams() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMetaAndMixedParams", McpMeta.class, + String.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(resourceAnnotation)) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("users/john"); + when(request.meta()).thenReturn(java.util.Map.of("key", "meta-value-xyz")); + + ReadResourceResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("User: john, Meta: meta-value-xyz"); + } + + @Test + public void testCallbackWithMultipleMetas() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMultipleMetas", McpMeta.class, + McpMeta.class, ReadResourceRequest.class); + + // This should throw an exception during callback creation due to multiple McpMeta + // parameters + assertThatThrownBy(() -> SyncMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } 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 6d48763..980fed9 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 @@ -7,12 +7,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.lang.reflect.Method; import java.util.List; +import java.util.Map; import java.util.function.BiFunction; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpMeta; +import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.ResourceAdaptor; @@ -123,6 +127,49 @@ public ReadResourceResult duplicateRequestParameters(ReadResourceRequest request return new ReadResourceResult(List.of()); } + // Methods for testing @McpMeta + public ReadResourceResult getResourceWithMeta(McpMeta meta, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with meta: " + metaValue + " for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public ReadResourceResult getResourceWithMetaOnly(McpMeta meta) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with only meta: " + metaValue; + return new ReadResourceResult(List.of(new TextResourceContents("test://resource", "text/plain", content))); + } + + @McpResource(uri = "users/{userId}/posts/{postId}") + public ReadResourceResult getResourceWithMetaAndUriVariables(McpMeta meta, String userId, String postId) { + String metaValue = (String) meta.get("testKey"); + String content = "User: " + userId + ", Post: " + postId + ", Meta: " + metaValue; + return new ReadResourceResult( + List.of(new TextResourceContents("users/" + userId + "/posts/" + postId, "text/plain", content))); + } + + public ReadResourceResult getResourceWithContextAndMeta(McpTransportContext context, McpMeta meta, + ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with context and meta: " + metaValue + " for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public ReadResourceResult getResourceWithMetaAndMixedParams(McpMeta meta, + @McpProgressToken String progressToken, ReadResourceRequest request) { + String metaValue = (String) meta.get("testKey"); + String content = "Content with meta: " + metaValue + " and progress: " + progressToken + " for " + + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + + public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta meta2, + ReadResourceRequest request) { + // This should cause a validation error during callback creation + String content = "Content with multiple metas for " + request.uri(); + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); + } + } // Helper method to create a mock McpResource annotation @@ -637,4 +684,185 @@ public void testUriVariableExtraction() throws Exception { .hasMessageContaining("Access error invoking resource method"); } + // Tests for @McpMeta functionality + @Test + public void testCallbackWithMeta() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "testValue")); + + ReadResourceResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: testValue for test/resource"); + } + + @Test + public void testCallbackWithMetaNull() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMeta", McpMeta.class, + ReadResourceRequest.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(null); + + ReadResourceResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with meta: null for test/resource"); + } + + @Test + public void testCallbackWithMetaOnly() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMetaOnly", McpMeta.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "metaOnlyValue")); + + ReadResourceResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with only meta: metaOnlyValue"); + } + + @Test + public void testCallbackWithMetaAndUriVariables() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMetaAndUriVariables", McpMeta.class, + String.class, String.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(resourceAnnotation)) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("users/123/posts/456"); + when(request.meta()).thenReturn(Map.of("testKey", "uriMetaValue")); + + ReadResourceResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("User: 123, Post: 456, Meta: uriMetaValue"); + } + + @Test + public void testCallbackWithContextAndMeta() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithContextAndMeta", McpTransportContext.class, + McpMeta.class, ReadResourceRequest.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "contextMetaValue")); + + ReadResourceResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()).isEqualTo("Content with context and meta: contextMetaValue for test/resource"); + } + + @Test + public void testCallbackWithMetaAndMixedParams() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMetaAndMixedParams", McpMeta.class, + String.class, ReadResourceRequest.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = mock(ReadResourceRequest.class); + when(request.uri()).thenReturn("test/resource"); + when(request.meta()).thenReturn(Map.of("testKey", "mixedMetaValue")); + when(request.progressToken()).thenReturn("mixedProgress"); + + ReadResourceResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0)).isInstanceOf(TextResourceContents.class); + TextResourceContents textContent = (TextResourceContents) result.contents().get(0); + assertThat(textContent.text()) + .isEqualTo("Content with meta: mixedMetaValue and progress: mixedProgress for test/resource"); + } + + @Test + public void testCallbackWithMultipleMetas() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithMultipleMetas", McpMeta.class, + McpMeta.class, ReadResourceRequest.class); + + // This should throw an exception during callback creation due to multiple + // McpMeta parameters + assertThatThrownBy(() -> SyncStatelessMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdaptor.asResource(createMockMcpResource())) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method cannot have more than one McpMeta parameter"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallbackTests.java index 501097a..c439df3 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallbackTests.java @@ -14,6 +14,7 @@ import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; import org.reactivestreams.Publisher; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; import reactor.core.publisher.Flux; @@ -147,6 +148,16 @@ private Mono privateMonoTool(String input) { return Mono.just("Private: " + input); } + /** + * Tool with McpMeta parameter + */ + @McpTool(name = "meta-mono-tool", description = "Mono tool with meta parameter") + public Mono metaMonoTool(@McpToolParam(description = "Input parameter", required = true) String input, + McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return Mono.just("Input: " + input + ", Meta: " + metaInfo); + } + // Non-reactive method that should cause error in async context @McpTool(name = "non-reactive-tool", description = "Non-reactive tool") public String nonReactiveTool(String input) { @@ -733,4 +744,51 @@ public void testCallbackReturnsCallToolResult() throws Exception { }).verifyComplete(); } + @Test + public void testAsyncMetaParameterInjection() throws Exception { + // Test that McpMeta parameter receives the meta from request in async context + TestAsyncToolProvider provider = new TestAsyncToolProvider(); + Method method = TestAsyncToolProvider.class.getMethod("metaMonoTool", String.class, McpMeta.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + + // Create request with meta data + CallToolRequest request = CallToolRequest.builder() + .name("meta-mono-tool") + .arguments(Map.of("input", "test-input")) + .meta(Map.of("userId", "user123", "sessionId", "session456")) + .build(); + + StepVerifier.create(callback.apply(exchange, request)).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).contains("Input: test-input") + .contains("Meta: {userId=user123, sessionId=session456}"); + }).verifyComplete(); + } + + @Test + public void testAsyncMetaParameterWithNullMeta() throws Exception { + // Test that McpMeta parameter handles null meta in async context + TestAsyncToolProvider provider = new TestAsyncToolProvider(); + Method method = TestAsyncToolProvider.class.getMethod("metaMonoTool", String.class, McpMeta.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + + // Create request without meta + CallToolRequest request = new CallToolRequest("meta-mono-tool", Map.of("input", "test-input")); + + StepVerifier.create(callback.apply(exchange, request)).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Input: test-input, Meta: {}"); + }).verifyComplete(); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java index b03a8be..cf1e4f4 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java @@ -14,6 +14,7 @@ import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; import org.reactivestreams.Publisher; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; import reactor.core.publisher.Flux; @@ -168,6 +169,16 @@ public Mono monoToolWithContextAndRequest(McpTransportContext context, C return Mono.just("Context present, Tool: " + request.name()); } + /** + * Mono tool with McpMeta parameter + */ + @McpTool(name = "meta-mono-tool", description = "Mono tool with meta parameter") + public Mono metaMonoTool(@McpToolParam(description = "Input parameter", required = true) String input, + McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return Mono.just("Input: " + input + ", Meta: " + metaInfo); + } + } public static class TestObject { @@ -846,4 +857,54 @@ public void testMonoToolWithContextAndRequest() throws Exception { }).verifyComplete(); } + @Test + public void testAsyncStatelessMetaParameterInjection() throws Exception { + // Test that McpMeta parameter receives the meta from request in async stateless + // context + TestAsyncStatelessToolProvider provider = new TestAsyncStatelessToolProvider(); + Method method = TestAsyncStatelessToolProvider.class.getMethod("metaMonoTool", String.class, McpMeta.class); + AsyncStatelessMcpToolMethodCallback callback = new AsyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + + // Create request with meta data + CallToolRequest request = CallToolRequest.builder() + .name("meta-mono-tool") + .arguments(Map.of("input", "test-input")) + .meta(Map.of("userId", "user123", "sessionId", "session456")) + .build(); + + StepVerifier.create(callback.apply(context, request)).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).contains("Input: test-input") + .contains("Meta: {userId=user123, sessionId=session456}"); + }).verifyComplete(); + } + + @Test + public void testAsyncStatelessMetaParameterWithNullMeta() throws Exception { + // Test that McpMeta parameter handles null meta in async stateless context + TestAsyncStatelessToolProvider provider = new TestAsyncStatelessToolProvider(); + Method method = TestAsyncStatelessToolProvider.class.getMethod("metaMonoTool", String.class, McpMeta.class); + AsyncStatelessMcpToolMethodCallback callback = new AsyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + + // Create request without meta + CallToolRequest request = new CallToolRequest("meta-mono-tool", Map.of("input", "test-input")); + + StepVerifier.create(callback.apply(context, request)).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Input: test-input, Meta: {}"); + }).verifyComplete(); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java index ab66fb5..aaa5b42 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java @@ -25,6 +25,7 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -149,6 +150,16 @@ public CallToolResult mixedSpecialParamsTool(McpSyncServerExchange exchange, Cal .build(); } + /** + * Tool with McpMeta parameter + */ + @McpTool(name = "meta-tool", description = "Tool with meta parameter") + public CallToolResult metaTool(@McpToolParam(description = "Input parameter", required = true) String input, + McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return CallToolResult.builder().addTextContent("Input: " + input + ", Meta: " + metaInfo).build(); + } + /** * Regular tool without CallToolRequest for comparison */ @@ -387,7 +398,7 @@ public void testSyncMcpToolProviderWithCallToolRequest() { var toolSpecs = toolProvider.getToolSpecifications(); // Should have all tools registered - assertThat(toolSpecs).hasSize(8); // All 8 tools from the provider + assertThat(toolSpecs).hasSize(9); // All 9 tools from the provider // Find the dynamic tool var dynamicToolSpec = toolSpecs.stream() @@ -598,4 +609,70 @@ public void testSyncMcpToolProviderWithProgressToken() { assertThat(schemaStr).doesNotContain("progressToken"); } + @Test + public void testMetaParameterInjection() throws Exception { + // Test that McpMeta parameter receives the meta from request + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("metaTool", String.class, McpMeta.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + + // Create request with meta data + CallToolRequest request = CallToolRequest.builder() + .name("meta-tool") + .arguments(Map.of("input", "test-input")) + .meta(Map.of("userId", "user123", "sessionId", "session456")) + .build(); + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(((TextContent) result.content().get(0)).text()).contains("Input: test-input") + .contains("Meta: {userId=user123, sessionId=session456}"); + } + + @Test + public void testMetaParameterWithNullMeta() throws Exception { + // Test that McpMeta parameter handles null meta + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("metaTool", String.class, McpMeta.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + + // Create request without meta + CallToolRequest request = new CallToolRequest("meta-tool", Map.of("input", "test-input")); + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Input: test-input, Meta: {}"); + } + + @Test + public void testJsonSchemaGenerationExcludesMeta() throws Exception { + // Test that schema generation excludes McpMeta parameters + Method metaMethod = CallToolRequestTestProvider.class.getMethod("metaTool", String.class, McpMeta.class); + String metaSchema = JsonSchemaGenerator.generateForMethodInput(metaMethod); + + // Parse the schema + JsonNode schemaNode = objectMapper.readTree(metaSchema); + + // Should only have the 'input' parameter, not the meta + assertThat(schemaNode.has("properties")).isTrue(); + JsonNode properties = schemaNode.get("properties"); + assertThat(properties.has("input")).isTrue(); + assertThat(properties.has("meta")).isFalse(); + assertThat(properties.size()).isEqualTo(1); + + // Check required array + assertThat(schemaNode.has("required")).isTrue(); + JsonNode required = schemaNode.get("required"); + assertThat(required.size()).isEqualTo(1); + assertThat(required.get(0).asText()).isEqualTo("input"); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java index d091eeb..aba50d9 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java @@ -13,6 +13,7 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -126,6 +127,16 @@ public String toolWithContextAndRequest(McpTransportContext context, CallToolReq return "Context present, Tool: " + request.name(); } + /** + * Tool with McpMeta parameter + */ + @McpTool(name = "meta-tool", description = "Tool with meta parameter") + public String metaTool(@McpToolParam(description = "Input parameter", required = true) String input, + McpMeta meta) { + String metaInfo = meta != null && meta.meta() != null ? meta.meta().toString() : "null"; + return "Input: " + input + ", Meta: " + metaInfo; + } + } public static class TestObject { @@ -612,4 +623,53 @@ public void testToolWithContextAndRequest() throws Exception { .isEqualTo("Context present, Tool: context-and-request-tool"); } + @Test + public void testStatelessMetaParameterInjection() throws Exception { + // Test that McpMeta parameter receives the meta from request in stateless context + TestToolProvider provider = new TestToolProvider(); + Method method = TestToolProvider.class.getMethod("metaTool", String.class, McpMeta.class); + SyncStatelessMcpToolMethodCallback callback = new SyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + + // Create request with meta data + CallToolRequest request = CallToolRequest.builder() + .name("meta-tool") + .arguments(Map.of("input", "test-input")) + .meta(Map.of("userId", "user123", "sessionId", "session456")) + .build(); + + CallToolResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).contains("Input: test-input") + .contains("Meta: {userId=user123, sessionId=session456}"); + } + + @Test + public void testStatelessMetaParameterWithNullMeta() throws Exception { + // Test that McpMeta parameter handles null meta in stateless context + TestToolProvider provider = new TestToolProvider(); + Method method = TestToolProvider.class.getMethod("metaTool", String.class, McpMeta.class); + SyncStatelessMcpToolMethodCallback callback = new SyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + + // Create request without meta + CallToolRequest request = new CallToolRequest("meta-tool", Map.of("input", "test-input")); + + CallToolResult result = callback.apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Input: test-input, Meta: {}"); + } + }