From 6decd7831114309fe75361f2e6f5a6e93dbc88f8 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 22 Sep 2025 14:01:52 +0200 Subject: [PATCH] refactor: enhance MCP method callbacks with improved type safety and error handling Those changes affect the Prompt and Resources Method callback implementations only - Add abstract methods `validateParamType` and `assignExchangeType` for better parameter handling - Implement strict type validation for exchange parameters (Sync vs Async vs Transport Context) - Replace custom exceptions (`McpPromptMethodException`, `McpResourceMethodException`) with standardized `McpError` - Add comprehensive parameter type validation with detailed error messages - Enhance exchange type assignment logic with proper type checking - Update all concrete implementations (Sync, Async, Stateless variants) for both prompt and resource callbacks - Add test coverage for new validation logic and error scenarios Signed-off-by: Christian Tzolov --- .../org/springaicommunity/mcp/ErrorUtils.java | 20 ++++ .../AbstractMcpPromptMethodCallback.java | 48 +++----- .../prompt/AsyncMcpPromptMethodCallback.java | 65 ++++++++++- ...AsyncStatelessMcpPromptMethodCallback.java | 56 +++++++++- .../prompt/SyncMcpPromptMethodCallback.java | 63 ++++++++++- .../SyncStatelessMcpPromptMethodCallback.java | 53 ++++++++- .../AbstractMcpResourceMethodCallback.java | 63 +++++------ .../AsyncMcpResourceMethodCallback.java | 60 +++++++++- ...yncStatelessMcpResourceMethodCallback.java | 53 ++++++++- .../SyncMcpResourceMethodCallback.java | 58 +++++++++- ...yncStatelessMcpResourceMethodCallback.java | 51 ++++++++- .../AsyncMcpPromptMethodCallbackTests.java | 105 ++++++++++++++++++ ...StatelessMcpPromptMethodCallbackTests.java | 85 ++++++++++++++ .../SyncMcpPromptMethodCallbackTests.java | 31 ++++++ ...StatelessMcpPromptMethodCallbackTests.java | 80 +++++++++++++ .../AsyncMcpResourceMethodCallbackTests.java | 68 +++++++++++- ...atelessMcpResourceMethodCallbackTests.java | 86 +++++++++++++- .../SyncMcpResourceMethodCallbackTests.java | 80 +++++++++++++ ...atelessMcpResourceMethodCallbackTests.java | 81 +++++++++++++- 19 files changed, 1106 insertions(+), 100 deletions(-) create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/ErrorUtils.java diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/ErrorUtils.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/ErrorUtils.java new file mode 100644 index 0000000..fca6806 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/ErrorUtils.java @@ -0,0 +1,20 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp; + +import java.util.Objects; + +public class ErrorUtils { + + public static Throwable findCauseUsingPlainJava(Throwable throwable) { + Objects.requireNonNull(throwable); + Throwable rootCause = throwable; + while (rootCause.getCause() != null && rootCause.getCause() != rootCause) { + rootCause = rootCause.getCause(); + } + return rootCause; + } + +} 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 ac12e41..a6ab94c 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 @@ -12,7 +12,9 @@ import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; - +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.Prompt; @@ -75,7 +77,10 @@ protected void validateMethod(Method method) { * @return true if the parameter type is compatible with the exchange type, false * otherwise */ - protected abstract boolean isExchangeOrContextType(Class paramType); + protected abstract boolean isSupportedExchangeOrContextType(Class paramType); + + protected void validateParamType(Class paramType) { + } /** * Validates method parameters. @@ -95,6 +100,8 @@ protected void validateParameters(Method method) { for (java.lang.reflect.Parameter param : parameters) { Class paramType = param.getType(); + this.validateParamType(paramType); + // Skip @McpProgressToken annotated parameters from validation if (param.isAnnotationPresent(McpProgressToken.class)) { if (hasProgressTokenParam) { @@ -115,7 +122,7 @@ protected void validateParameters(Method method) { continue; } - if (isExchangeOrContextType(paramType)) { + if (isSupportedExchangeOrContextType(paramType)) { if (hasExchangeParam) { throw new IllegalArgumentException("Method cannot have more than one exchange parameter: " + method.getName() + " in " + method.getDeclaringClass().getName()); @@ -140,6 +147,8 @@ else if (Map.class.isAssignableFrom(paramType)) { } } + protected abstract Object assignExchangeType(Class paramType, Object exchange); + /** * Builds the arguments array for invoking the method. *

@@ -182,8 +191,11 @@ protected Object[] buildArgs(Method method, Object exchange, GetPromptRequest re java.lang.reflect.Parameter param = parameters[i]; Class paramType = param.getType(); - if (isExchangeOrContextType(paramType)) { - args[i] = exchange; + if (McpTransportContext.class.isAssignableFrom(paramType) + || McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + + args[i] = this.assignExchangeType(paramType, exchange); } else if (GetPromptRequest.class.isAssignableFrom(paramType)) { args[i] = request; @@ -367,30 +379,4 @@ protected void validate() { } - /** - * Exception thrown when there is an error invoking a prompt method. - */ - public static class McpPromptMethodException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - /** - * Constructs a new exception with the specified detail message and cause. - * @param message The detail message - * @param cause The cause - */ - public McpPromptMethodException(String message, Throwable cause) { - super(message, cause); - } - - /** - * Constructs a new exception with the specified detail message. - * @param message The detail message - */ - public McpPromptMethodException(String message) { - super(message); - } - - } - } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallback.java index 198c981..9e44c08 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncMcpPromptMethodCallback.java @@ -7,9 +7,13 @@ import java.lang.reflect.Method; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpPrompt; - +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import reactor.core.publisher.Mono; @@ -31,6 +35,48 @@ private AsyncMcpPromptMethodCallback(Builder builder) { super(builder.method, builder.bean, builder.prompt); } + @Override + protected void validateParamType(Class paramType) { + + if (McpSyncServerExchange.class.isAssignableFrom(paramType)) { + throw new IllegalArgumentException("Async prompt method must not declare parameter of type: " + + paramType.getName() + ". Use McpAsyncServerExchange instead." + " Method: " + + this.method.getName() + " in " + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + throw new IllegalArgumentException("Unsupported Async exchange type: " + + syncServerExchange.getClass().getName() + " for Async method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + return asyncServerExchange.transportContext(); + } + } + else if (McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + return asyncServerExchange; + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for Async method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given exchange and request. *

@@ -69,15 +115,24 @@ public Mono apply(McpAsyncServerExchange exchange, GetPromptReq } } catch (Exception e) { - return Mono - .error(new McpPromptMethodException("Error invoking prompt method: " + this.method.getName(), e)); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + return Mono.error(mcpError); + } + + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking prompt method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build()); } }); } @Override - protected boolean isExchangeOrContextType(Class paramType) { - return McpAsyncServerExchange.class.isAssignableFrom(paramType); + protected boolean isSupportedExchangeOrContextType(Class paramType) { + return (McpAsyncServerExchange.class.isAssignableFrom(paramType) + || McpTransportContext.class.isAssignableFrom(paramType)); } @Override diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallback.java index 6197f49..760ade9 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/AsyncStatelessMcpPromptMethodCallback.java @@ -8,8 +8,13 @@ import java.util.List; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpPrompt; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.PromptMessage; @@ -32,6 +37,42 @@ private AsyncStatelessMcpPromptMethodCallback(Builder builder) { super(builder.method, builder.bean, builder.prompt); } + @Override + protected void validateParamType(Class paramType) { + + if (McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + + throw new IllegalArgumentException( + "Stateless Streamable-Http prompt method must not declare parameter of type: " + paramType.getName() + + ". Use McpTransportContext instead." + " Method: " + this.method.getName() + " in " + + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + throw new IllegalArgumentException("Unsupported Sync exchange type: " + + syncServerExchange.getClass().getName() + " for Sync method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + return asyncServerExchange.transportContext(); + } + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given context and request. *

@@ -70,14 +111,23 @@ public Mono apply(McpTransportContext context, GetPromptRequest } } catch (Exception e) { - return Mono - .error(new McpPromptMethodException("Error invoking prompt method: " + this.method.getName(), e)); + + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + return Mono.error(mcpError); + } + + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking prompt method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build()); } }); } @Override - protected boolean isExchangeOrContextType(Class paramType) { + protected boolean isSupportedExchangeOrContextType(Class paramType) { return McpTransportContext.class.isAssignableFrom(paramType); } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallback.java index 4f4c6a4..8d7a087 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncMcpPromptMethodCallback.java @@ -8,9 +8,13 @@ import java.util.List; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpPrompt; - +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.PromptMessage; @@ -31,6 +35,47 @@ private SyncMcpPromptMethodCallback(Builder builder) { super(builder.method, builder.bean, builder.prompt); } + @Override + protected void validateParamType(Class paramType) { + + if (McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + throw new IllegalArgumentException("Sync prompt method must not declare parameter of type: " + + paramType.getName() + ". Use McpSyncServerExchange instead." + " Method: " + this.method.getName() + + " in " + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + return syncServerExchange.transportContext(); + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + throw new IllegalArgumentException("Unsupported Async exchange type: " + + asyncServerExchange.getClass().getName() + " for Sync method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + } + } + else if (McpSyncServerExchange.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpSyncServerExchange syncServerExchange) { + return syncServerExchange; + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for Sync method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given exchange and request. *

@@ -62,13 +107,23 @@ public GetPromptResult apply(McpSyncServerExchange exchange, GetPromptRequest re return promptResult; } catch (Exception e) { - throw new McpPromptMethodException("Error invoking prompt method: " + this.method.getName(), e); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + throw mcpError; + } + + throw McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking prompt method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + "./nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build(); } } @Override - protected boolean isExchangeOrContextType(Class paramType) { - return McpSyncServerExchange.class.isAssignableFrom(paramType); + protected boolean isSupportedExchangeOrContextType(Class paramType) { + return (McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpTransportContext.class.isAssignableFrom(paramType)); } @Override diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallback.java index cb829bb..785c9a8 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/prompt/SyncStatelessMcpPromptMethodCallback.java @@ -8,8 +8,13 @@ import java.util.List; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpPrompt; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.PromptMessage; @@ -30,6 +35,41 @@ private SyncStatelessMcpPromptMethodCallback(Builder builder) { super(builder.method, builder.bean, builder.prompt); } + @Override + protected void validateParamType(Class paramType) { + + if (McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + + throw new IllegalArgumentException( + "Stateless Streamable-Http prompt method must not declare parameter of type: " + paramType.getName() + + ". Use McpTransportContext instead." + " Method: " + this.method.getName() + " in " + + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + return syncServerExchange.transportContext(); + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + throw new IllegalArgumentException("Unsupported Async exchange type: " + + asyncServerExchange.getClass().getName() + " for Sync method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + } + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given context and request. *

@@ -61,12 +101,21 @@ public GetPromptResult apply(McpTransportContext context, GetPromptRequest reque return promptResult; } catch (Exception e) { - throw new McpPromptMethodException("Error invoking prompt method: " + this.method.getName(), e); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + throw mcpError; + } + + throw McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking prompt method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build(); } } @Override - protected boolean isExchangeOrContextType(Class paramType) { + protected boolean isSupportedExchangeOrContextType(Class paramType) { return McpTransportContext.class.isAssignableFrom(paramType); } 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 eb6645e..70113c3 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 @@ -12,7 +12,9 @@ import org.springaicommunity.mcp.annotation.McpMeta; import org.springaicommunity.mcp.annotation.McpProgressToken; - +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.util.Assert; @@ -215,6 +217,9 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType) } } + protected void validateParamType(Class paramType) { + } + /** * Validates method parameters when URI variables are present. This method provides * common validation logic and delegates exchange type checking to subclasses. @@ -236,6 +241,9 @@ protected void validateParametersWithUriVariables(Method method) { } else { Class paramType = param.getType(); + + this.validateParamType(paramType); + if (McpMeta.class.isAssignableFrom(paramType)) { metaParamCount++; } @@ -295,6 +303,8 @@ else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { } } + protected abstract Object assignExchangeType(Class paramType, Object exchange); + /** * Builds the arguments array for invoking the method. *

@@ -313,14 +323,21 @@ protected Object[] buildArgs(Method method, Object exchange, ReadResourceRequest // First, handle @McpProgressToken and McpMeta parameters for (int i = 0; i < parameters.length; i++) { + Class paramType = parameters[i].getType(); 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())) { + else if (McpMeta.class.isAssignableFrom(paramType)) { // Inject McpMeta with request metadata args[i] = request != null ? new McpMeta(request.meta()) : new McpMeta(null); } + else if (McpTransportContext.class.isAssignableFrom(paramType) + || McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + + args[i] = this.assignExchangeType(paramType, exchange); + } } if (!this.uriVariables.isEmpty()) { @@ -354,15 +371,13 @@ protected void buildArgsWithUriVariables(Parameter[] parameters, Object[] args, // Skip if parameter is annotated with @McpProgressToken or is McpMeta // (already handled) if (parameters[i].isAnnotationPresent(McpProgressToken.class) - || McpMeta.class.isAssignableFrom(parameters[i].getType())) { + || McpMeta.class.isAssignableFrom(parameters[i].getType()) + || isExchangeOrContextType(parameters[i].getType())) { continue; } Class paramType = parameters[i].getType(); - if (isExchangeOrContextType(paramType)) { - args[i] = exchange; - } - else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { + if (ReadResourceRequest.class.isAssignableFrom(paramType)) { args[i] = request; } } @@ -408,17 +423,15 @@ protected void buildArgsWithoutUriVariables(Parameter[] parameters, Object[] arg // Skip if parameter is annotated with @McpProgressToken or is McpMeta // (already handled) if (parameters[i].isAnnotationPresent(McpProgressToken.class) - || McpMeta.class.isAssignableFrom(parameters[i].getType())) { + || McpMeta.class.isAssignableFrom(parameters[i].getType()) + || isExchangeOrContextType(parameters[i].getType())) { continue; } Parameter param = parameters[i]; Class paramType = param.getType(); - if (isExchangeOrContextType(paramType)) { - args[i] = exchange; - } - else if (ReadResourceRequest.class.isAssignableFrom(paramType)) { + if (ReadResourceRequest.class.isAssignableFrom(paramType)) { args[i] = request; } else if (String.class.isAssignableFrom(paramType)) { @@ -447,32 +460,6 @@ public ContentType contentType() { return this.contentType; } - /** - * Exception thrown when there is an error invoking a resource method. - */ - public static class McpResourceMethodException extends RuntimeException { - - private static final long serialVersionUID = 1L; - - /** - * Constructs a new exception with the specified detail message and cause. - * @param message The detail message - * @param cause The cause - */ - public McpResourceMethodException(String message, Throwable cause) { - super(message, cause); - } - - /** - * Constructs a new exception with the specified detail message. - * @param message The detail message - */ - public McpResourceMethodException(String message) { - super(message); - } - - } - /** * Abstract builder for creating McpResourceMethodCallback instances. *

diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallback.java index aa4ae06..e53b193 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncMcpResourceMethodCallback.java @@ -8,9 +8,13 @@ import java.util.Map; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpResource; - +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; @@ -36,6 +40,48 @@ private AsyncMcpResourceMethodCallback(Builder builder) { this.validateMethod(this.method); } + @Override + protected void validateParamType(Class paramType) { + + if (McpSyncServerExchange.class.isAssignableFrom(paramType)) { + throw new IllegalArgumentException("Async prompt method must not declare parameter of type: " + + paramType.getName() + ". Use McpAsyncServerExchange instead." + " Method: " + + this.method.getName() + " in " + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + throw new IllegalArgumentException("Unsupported Async exchange type: " + + syncServerExchange.getClass().getName() + " for Async method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + return asyncServerExchange.transportContext(); + } + } + else if (McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + return asyncServerExchange; + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for Async method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given exchange and request. *

@@ -90,8 +136,16 @@ public Mono apply(McpAsyncServerExchange exchange, ReadResou } } catch (Exception e) { - return Mono.error( - new McpResourceMethodException("Error invoking resource method: " + this.method.getName(), e)); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + return Mono.error(mcpError); + } + + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking resource method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build()); } }); } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallback.java index c636aa5..b222105 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/AsyncStatelessMcpResourceMethodCallback.java @@ -8,8 +8,13 @@ import java.util.Map; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpResource; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; @@ -35,6 +40,42 @@ private AsyncStatelessMcpResourceMethodCallback(Builder builder) { this.validateMethod(this.method); } + @Override + protected void validateParamType(Class paramType) { + + if (McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + + throw new IllegalArgumentException( + "Stateless Streamable-Http prompt method must not declare parameter of type: " + paramType.getName() + + ". Use McpTransportContext instead." + " Method: " + this.method.getName() + " in " + + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + throw new IllegalArgumentException("Unsupported Sync exchange type: " + + syncServerExchange.getClass().getName() + " for Sync method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + return asyncServerExchange.transportContext(); + } + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given context and request. *

@@ -89,8 +130,16 @@ public Mono apply(McpTransportContext context, ReadResourceR } } catch (Exception e) { - return Mono.error( - new McpResourceMethodException("Error invoking resource method: " + this.method.getName(), e)); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + return Mono.error(mcpError); + } + + return Mono.error(McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking resource method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build()); } }); } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallback.java index f0b2d25..ed4f592 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncMcpResourceMethodCallback.java @@ -9,9 +9,13 @@ import java.util.Map; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpResource; - +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; @@ -34,6 +38,47 @@ private SyncMcpResourceMethodCallback(Builder builder) { this.validateMethod(this.method); } + @Override + protected void validateParamType(Class paramType) { + + if (McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + throw new IllegalArgumentException("Sync prompt method must not declare parameter of type: " + + paramType.getName() + ". Use McpSyncServerExchange instead." + " Method: " + this.method.getName() + + " in " + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + return syncServerExchange.transportContext(); + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + throw new IllegalArgumentException("Unsupported Async exchange type: " + + asyncServerExchange.getClass().getName() + " for Sync method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + } + } + else if (McpSyncServerExchange.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpSyncServerExchange syncServerExchange) { + return syncServerExchange; + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for Sync method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given exchange and request. *

@@ -77,7 +122,16 @@ public ReadResourceResult apply(McpSyncServerExchange exchange, ReadResourceRequ this.contentType); } catch (Exception e) { - throw new McpResourceMethodException("Access error invoking resource method: " + this.method.getName(), e); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + throw mcpError; + } + + throw McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking resource method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build(); } } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallback.java index a4d14d4..d203665 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/resource/SyncStatelessMcpResourceMethodCallback.java @@ -9,8 +9,13 @@ import java.util.Map; import java.util.function.BiFunction; +import org.springaicommunity.mcp.ErrorUtils; import org.springaicommunity.mcp.annotation.McpResource; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; import io.modelcontextprotocol.spec.McpSchema.ResourceContents; @@ -34,6 +39,41 @@ private SyncStatelessMcpResourceMethodCallback(Builder builder) { this.validateMethod(this.method); } + @Override + protected void validateParamType(Class paramType) { + + if (McpSyncServerExchange.class.isAssignableFrom(paramType) + || McpAsyncServerExchange.class.isAssignableFrom(paramType)) { + + throw new IllegalArgumentException( + "Stateless Streamable-Http prompt method must not declare parameter of type: " + paramType.getName() + + ". Use McpTransportContext instead." + " Method: " + this.method.getName() + " in " + + this.method.getDeclaringClass().getName()); + } + } + + @Override + protected Object assignExchangeType(Class paramType, Object exchange) { + + if (McpTransportContext.class.isAssignableFrom(paramType)) { + if (exchange instanceof McpTransportContext transportContext) { + return transportContext; + } + else if (exchange instanceof McpSyncServerExchange syncServerExchange) { + return syncServerExchange.transportContext(); + } + else if (exchange instanceof McpAsyncServerExchange asyncServerExchange) { + throw new IllegalArgumentException("Unsupported Async exchange type: " + + asyncServerExchange.getClass().getName() + " for Sync method: " + method.getName() + " in " + + method.getDeclaringClass().getName()); + } + } + + throw new IllegalArgumentException( + "Unsupported exchange type: " + (exchange != null ? exchange.getClass().getName() : "null") + + " for method: " + method.getName() + " in " + method.getDeclaringClass().getName()); + } + /** * Apply the callback to the given context and request. *

@@ -77,7 +117,16 @@ public ReadResourceResult apply(McpTransportContext context, ReadResourceRequest this.contentType); } catch (Exception e) { - throw new McpResourceMethodException("Access error invoking resource method: " + this.method.getName(), e); + if (e instanceof McpError mcpError && mcpError.getJsonRpcError() != null) { + throw mcpError; + } + + throw McpError.builder(ErrorCodes.INVALID_PARAMS) + .message("Error invoking resource method: " + this.method.getName() + " in " + + this.bean.getClass().getName() + ". /nCause: " + + ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .data(ErrorUtils.findCauseUsingPlainJava(e).getMessage()) + .build(); } } 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 589c24b..db2e412 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 @@ -10,7 +10,10 @@ import java.util.Map; import java.util.function.BiFunction; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.Prompt; @@ -29,6 +32,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 AsyncMcpPromptMethodCallback}. @@ -161,6 +165,24 @@ public Mono duplicateMetaParameters(McpMeta meta1, McpMeta meta return Mono.just(new GetPromptResult("Invalid", List.of())); } + @McpPrompt(name = "failing-prompt", description = "A prompt that throws an exception") + public Mono getFailingPrompt(GetPromptRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for async methods + public Mono invalidSyncExchangeParameter(McpSyncServerExchange exchange, + GetPromptRequest request) { + return Mono.just(new GetPromptResult("Invalid", List.of())); + } + + @McpPrompt(name = "transport-context-prompt", description = "A prompt with transport context") + public Mono getPromptWithTransportContext(McpTransportContext context, + GetPromptRequest request) { + return Mono.just(new GetPromptResult("Transport context prompt", List.of(new PromptMessage(Role.ASSISTANT, + new TextContent("Hello with transport context from " + request.name()))))); + } + } private Prompt createTestPrompt(String name, String description) { @@ -476,4 +498,87 @@ public void testDuplicateMetaParameters() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testMethodInvocationError() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getFailingPrompt", GetPromptRequest.class); + + Prompt prompt = createTestPrompt("failing-prompt", "A prompt that throws an exception"); + + BiFunction> callback = AsyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + GetPromptRequest request = new GetPromptRequest("failing-prompt", args); + + Mono resultMono = callback.apply(exchange, request); + + // The new error handling should throw McpError instead of custom exceptions + StepVerifier.create(resultMono) + .expectErrorMatches(throwable -> throwable instanceof McpError + && throwable.getMessage().contains("Error invoking prompt method")) + .verify(); + } + + @Test + public void testInvalidSyncExchangeParameter() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("invalidSyncExchangeParameter", McpSyncServerExchange.class, + GetPromptRequest.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameter type"); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy( + () -> AsyncMcpPromptMethodCallback.builder().method(method).bean(provider).prompt(prompt).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Async prompt method must not declare parameter of type") + .hasMessageContaining("McpSyncServerExchange") + .hasMessageContaining("Use McpAsyncServerExchange instead"); + } + + @Test + public void testCallbackWithTransportContext() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getPromptWithTransportContext", McpTransportContext.class, + GetPromptRequest.class); + + Prompt prompt = createTestPrompt("transport-context-prompt", "A prompt with transport context"); + + BiFunction> callback = AsyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + McpTransportContext context = mock(McpTransportContext.class); + + // Mock the exchange to return the transport context + when(exchange.transportContext()).thenReturn(context); + + Map args = new HashMap<>(); + args.put("name", "John"); + GetPromptRequest request = new GetPromptRequest("transport-context-prompt", args); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.description()).isEqualTo("Transport context 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 with transport context from transport-context-prompt"); + }).verifyComplete(); + } + } 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 66a56b6..70fdeca 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 @@ -11,6 +11,9 @@ import java.util.function.BiFunction; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.Prompt; @@ -164,6 +167,22 @@ public Mono duplicateMetaParameters(McpMeta meta1, McpMeta meta return Mono.just(new GetPromptResult("Invalid", List.of())); } + @McpPrompt(name = "failing-prompt", description = "A prompt that throws an exception") + public Mono getFailingPrompt(GetPromptRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for stateless methods + public Mono invalidSyncExchangeParameter(McpSyncServerExchange exchange, + GetPromptRequest request) { + return Mono.just(new GetPromptResult("Invalid", List.of())); + } + + public Mono invalidAsyncExchangeParameter(McpAsyncServerExchange exchange, + GetPromptRequest request) { + return Mono.just(new GetPromptResult("Invalid", List.of())); + } + } private Prompt createTestPrompt(String name, String description) { @@ -826,4 +845,70 @@ public void testDuplicateMetaParameters() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testMethodInvocationError() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getFailingPrompt", GetPromptRequest.class); + + Prompt prompt = createTestPrompt("failing-prompt", "A prompt that throws an exception"); + + BiFunction> callback = AsyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + GetPromptRequest request = new GetPromptRequest("failing-prompt", args); + + Mono resultMono = callback.apply(context, request); + + // The new error handling should throw McpError instead of custom exceptions + StepVerifier.create(resultMono) + .expectErrorMatches(throwable -> throwable instanceof McpError + && throwable.getMessage().contains("Error invoking prompt method")) + .verify(); + } + + @Test + public void testInvalidSyncExchangeParameter() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("invalidSyncExchangeParameter", McpSyncServerExchange.class, + GetPromptRequest.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameter type"); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> AsyncStatelessMcpPromptMethodCallback.builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stateless Streamable-Http prompt method must not declare parameter of type") + .hasMessageContaining("McpSyncServerExchange") + .hasMessageContaining("Use McpTransportContext instead"); + } + + @Test + public void testInvalidAsyncExchangeParameter() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("invalidAsyncExchangeParameter", + McpAsyncServerExchange.class, GetPromptRequest.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameter type"); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> AsyncStatelessMcpPromptMethodCallback.builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stateless Streamable-Http prompt method must not declare parameter of type") + .hasMessageContaining("McpAsyncServerExchange") + .hasMessageContaining("Use McpTransportContext instead"); + } + } 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 16f80c8..4e59b18 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 @@ -21,6 +21,7 @@ import org.springaicommunity.mcp.annotation.McpPrompt; import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.Prompt; @@ -38,6 +39,11 @@ public class SyncMcpPromptMethodCallbackTests { private static class TestPromptProvider { + @McpPrompt(name = "failing-prompt", description = "A prompt that throws an exception") + public GetPromptResult getFailingPrompt(GetPromptRequest request) { + throw new RuntimeException("Test exception"); + } + @McpPrompt(name = "greeting", description = "A simple greeting prompt") public GetPromptResult getPromptWithRequest(GetPromptRequest request) { return new GetPromptResult("Greeting prompt", @@ -163,6 +169,31 @@ private Prompt createTestPrompt(String name, String description) { new PromptArgument("age", "User's age", false))); } + @Test + public void testMethodInvocationError() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getFailingPrompt", GetPromptRequest.class); + + Prompt prompt = createTestPrompt("failing-prompt", "A prompt that throws an exception"); + + BiFunction callback = SyncMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + Map args = new HashMap<>(); + args.put("name", "John"); + GetPromptRequest request = new GetPromptRequest("failing-prompt", args); + + // The new error handling should throw McpError instead of + // McpPromptMethodException + assertThatThrownBy(() -> callback.apply(exchange, request)).isInstanceOf(McpError.class) + .hasMessageContaining("Error invoking prompt method"); + } + @Test public void testCallbackWithRequestParameter() throws Exception { TestPromptProvider provider = new TestPromptProvider(); 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 46c4bd4..67f035a 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 @@ -20,6 +20,9 @@ import org.springaicommunity.mcp.annotation.McpPrompt; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; import io.modelcontextprotocol.spec.McpSchema.Prompt; @@ -131,6 +134,21 @@ public GetPromptResult duplicateMetaParameters(McpMeta meta1, McpMeta meta2) { return new GetPromptResult("Invalid", List.of()); } + @McpPrompt(name = "failing-prompt", description = "A prompt that throws an exception") + public GetPromptResult getFailingPrompt(GetPromptRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for stateless methods + public GetPromptResult invalidSyncExchangeParameter(McpSyncServerExchange exchange, GetPromptRequest request) { + return new GetPromptResult("Invalid", List.of()); + } + + public GetPromptResult invalidAsyncExchangeParameter(McpAsyncServerExchange exchange, + GetPromptRequest request) { + return new GetPromptResult("Invalid", List.of()); + } + } private Prompt createTestPrompt(String name, String description) { @@ -609,4 +627,66 @@ public void testDuplicateMetaParameters() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testMethodInvocationError() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("getFailingPrompt", GetPromptRequest.class); + + Prompt prompt = createTestPrompt("failing-prompt", "A prompt that throws an exception"); + + BiFunction callback = SyncStatelessMcpPromptMethodCallback + .builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + Map args = new HashMap<>(); + args.put("name", "John"); + GetPromptRequest request = new GetPromptRequest("failing-prompt", args); + + // The new error handling should throw McpError instead of the old exception type + assertThatThrownBy(() -> callback.apply(context, request)).isInstanceOf(McpError.class) + .hasMessageContaining("Error invoking prompt method"); + } + + @Test + public void testInvalidSyncExchangeParameter() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("invalidSyncExchangeParameter", McpSyncServerExchange.class, + GetPromptRequest.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameter type"); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> SyncStatelessMcpPromptMethodCallback.builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stateless Streamable-Http prompt method must not declare parameter of type") + .hasMessageContaining("McpSyncServerExchange") + .hasMessageContaining("Use McpTransportContext instead"); + } + + @Test + public void testInvalidAsyncExchangeParameter() throws Exception { + TestPromptProvider provider = new TestPromptProvider(); + Method method = TestPromptProvider.class.getMethod("invalidAsyncExchangeParameter", + McpAsyncServerExchange.class, GetPromptRequest.class); + + Prompt prompt = createTestPrompt("invalid", "Invalid parameter type"); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> SyncStatelessMcpPromptMethodCallback.builder() + .method(method) + .bean(provider) + .prompt(prompt) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stateless Streamable-Http prompt method must not declare parameter of type") + .hasMessageContaining("McpAsyncServerExchange") + .hasMessageContaining("Use McpTransportContext instead"); + } + } 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 447a990..3eed2b5 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 @@ -9,7 +9,10 @@ import java.util.Map; import java.util.function.BiFunction; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; @@ -233,6 +236,23 @@ public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta me return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); } + @McpResource(uri = "failing-resource://resource", description = "A resource that throws an exception") + public Mono getFailingResource(ReadResourceRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for async methods + public Mono invalidSyncExchangeParameter(McpSyncServerExchange exchange, + ReadResourceRequest request) { + return Mono.just(new ReadResourceResult(List.of())); + } + + public Mono getResourceWithTransportContext(McpTransportContext context, + ReadResourceRequest request) { + return Mono.just(new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", + "Content with transport context for " + request.uri())))); + } + } // Helper method to create a mock McpResource annotation @@ -718,9 +738,8 @@ public McpUriTemplateManager create(String uriTemplate) { Mono resultMono = callbackWithMockTemplate.apply(exchange, request); StepVerifier.create(resultMono) - .expectErrorMatches( - throwable -> throwable instanceof AsyncMcpResourceMethodCallback.McpResourceMethodException - && throwable.getMessage().contains("Error invoking resource method")) + .expectErrorMatches(throwable -> throwable instanceof McpError + && throwable.getMessage().contains("Error invoking resource method")) .verify(); } @@ -1155,4 +1174,47 @@ public void testCallbackWithMultipleMetas() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testNewMethodInvocationError() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("getFailingResource", ReadResourceRequest.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction> callback = AsyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(resourceAnnotation)) + .build(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + ReadResourceRequest request = new ReadResourceRequest("failing-resource://resource"); + + Mono resultMono = callback.apply(exchange, request); + + // The new error handling should throw McpError instead of custom exceptions + StepVerifier.create(resultMono) + .expectErrorMatches(throwable -> throwable instanceof McpError + && throwable.getMessage().contains("Error invoking resource method")) + .verify(); + } + + @Test + public void testInvalidSyncExchangeParameter() throws Exception { + TestAsyncResourceProvider provider = new TestAsyncResourceProvider(); + Method method = TestAsyncResourceProvider.class.getMethod("invalidSyncExchangeParameter", + McpSyncServerExchange.class, ReadResourceRequest.class); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> AsyncMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(createMockMcpResource())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpSyncServerExchange"); + } + } 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 73bdb70..be03bea 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 @@ -10,6 +10,9 @@ import java.util.function.BiFunction; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; @@ -188,6 +191,22 @@ public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta me return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); } + @McpResource(uri = "failing-resource://resource", description = "A resource that throws an exception") + public Mono getFailingResource(ReadResourceRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for stateless methods + public Mono invalidSyncExchangeParameter(McpSyncServerExchange exchange, + ReadResourceRequest request) { + return Mono.just(new ReadResourceResult(List.of())); + } + + public Mono invalidAsyncExchangeParameter(McpAsyncServerExchange exchange, + ReadResourceRequest request) { + return Mono.just(new ReadResourceResult(List.of())); + } + } // Helper method to create a mock McpResource annotation @@ -681,9 +700,8 @@ public McpUriTemplateManager create(String uriTemplate) { Mono resultMono = callbackWithMockTemplate.apply(context, request); StepVerifier.create(resultMono) - .expectErrorMatches( - throwable -> throwable instanceof AsyncStatelessMcpResourceMethodCallback.McpResourceMethodException - && throwable.getMessage().contains("Error invoking resource method")) + .expectErrorMatches(throwable -> throwable instanceof McpError + && throwable.getMessage().contains("Error invoking resource method")) .verify(); } @@ -965,4 +983,66 @@ public void testCallbackWithMultipleMetas() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testNewMethodInvocationError() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("getFailingResource", + ReadResourceRequest.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction> callback = AsyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(resourceAnnotation)) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("failing-resource://resource"); + + Mono resultMono = callback.apply(context, request); + + // The new error handling should throw McpError instead of custom exceptions + StepVerifier.create(resultMono) + .expectErrorMatches(throwable -> throwable instanceof McpError + && throwable.getMessage().contains("Error invoking resource method")) + .verify(); + } + + @Test + public void testInvalidSyncExchangeParameter() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("invalidSyncExchangeParameter", + McpSyncServerExchange.class, ReadResourceRequest.class); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> AsyncStatelessMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(createMockMcpResource())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpSyncServerExchange"); + } + + @Test + public void testInvalidAsyncExchangeParameter() throws Exception { + TestAsyncStatelessResourceProvider provider = new TestAsyncStatelessResourceProvider(); + Method method = TestAsyncStatelessResourceProvider.class.getMethod("invalidAsyncExchangeParameter", + McpAsyncServerExchange.class, ReadResourceRequest.class); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> AsyncStatelessMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(createMockMcpResource())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpAsyncServerExchange"); + } + } 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 476d7bc..1ea3875 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 @@ -8,7 +8,10 @@ import java.util.List; import java.util.function.BiFunction; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; @@ -204,6 +207,24 @@ public ReadResourceResult duplicateRequestParameters(ReadResourceRequest request return new ReadResourceResult(List.of()); } + @McpResource(uri = "failing-resource://resource", description = "A resource that throws an exception") + public ReadResourceResult getFailingResource(ReadResourceRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for sync methods + public ReadResourceResult invalidAsyncExchangeParameter(McpAsyncServerExchange exchange, + ReadResourceRequest request) { + return new ReadResourceResult(List.of()); + } + + @McpResource(uri = "transport-context://resource", description = "A resource with transport context") + public ReadResourceResult getResourceWithTransportContext(McpTransportContext context, + ReadResourceRequest request) { + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", + "Content with transport context for " + request.uri()))); + } + } // Helper method to create a mock McpResource annotation @@ -1125,4 +1146,63 @@ public void testCallbackWithMultipleMetas() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testMethodInvocationError() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getFailingResource", ReadResourceRequest.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction callback = SyncMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(resourceAnnotation)) + .build(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + ReadResourceRequest request = new ReadResourceRequest("failing-resource://resource"); + + // The new error handling should throw McpError instead of custom exceptions + assertThatThrownBy(() -> callback.apply(exchange, request)).isInstanceOf(McpError.class) + .hasMessageContaining("Error invoking resource method"); + } + + @Test + public void testInvalidAsyncExchangeParameter() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("invalidAsyncExchangeParameter", + McpAsyncServerExchange.class, ReadResourceRequest.class); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> SyncMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(createMockMcpResource())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpAsyncServerExchange"); + } + + @Test + public void testCallbackWithTransportContext() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getResourceWithTransportContext", + McpTransportContext.class, ReadResourceRequest.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + // Should fail during callback creation due to parameter validation - + // McpTransportContext is not supported in resource methods + assertThatThrownBy(() -> SyncMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(resourceAnnotation)) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpTransportContext"); + } + } 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 5348e78..eee5647 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 @@ -10,6 +10,9 @@ import java.util.function.BiFunction; import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents; import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; @@ -170,6 +173,22 @@ public ReadResourceResult getResourceWithMultipleMetas(McpMeta meta1, McpMeta me return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", content))); } + @McpResource(uri = "failing-resource://resource", description = "A resource that throws an exception") + public ReadResourceResult getFailingResource(ReadResourceRequest request) { + throw new RuntimeException("Test exception"); + } + + // Invalid parameter types for stateless methods + public ReadResourceResult invalidSyncExchangeParameter(McpSyncServerExchange exchange, + ReadResourceRequest request) { + return new ReadResourceResult(List.of()); + } + + public ReadResourceResult invalidAsyncExchangeParameter(McpAsyncServerExchange exchange, + ReadResourceRequest request) { + return new ReadResourceResult(List.of()); + } + } // Helper method to create a mock McpResource annotation @@ -739,9 +758,8 @@ public void testUriVariableExtraction() throws Exception { // Test with mismatched URI that doesn't contain expected variables ReadResourceRequest invalidRequest = new ReadResourceRequest("invalid/uri/format"); - assertThatThrownBy(() -> callback.apply(context, invalidRequest)) - .isInstanceOf(AbstractMcpResourceMethodCallback.McpResourceMethodException.class) - .hasMessageContaining("Access error invoking resource method"); + assertThatThrownBy(() -> callback.apply(context, invalidRequest)).isInstanceOf(McpError.class) + .hasMessageContaining("Failed to extract all URI variables from request URI: invalid/uri/format."); } // Tests for @McpMeta functionality @@ -925,4 +943,61 @@ public void testCallbackWithMultipleMetas() throws Exception { .hasMessageContaining("Method cannot have more than one McpMeta parameter"); } + @Test + public void testMethodInvocationError() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("getFailingResource", ReadResourceRequest.class); + McpResource resourceAnnotation = method.getAnnotation(McpResource.class); + + BiFunction callback = SyncStatelessMcpResourceMethodCallback + .builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(resourceAnnotation)) + .build(); + + McpTransportContext context = mock(McpTransportContext.class); + ReadResourceRequest request = new ReadResourceRequest("failing-resource://resource"); + + // The new error handling should throw McpError instead of custom exceptions + assertThatThrownBy(() -> callback.apply(context, request)).isInstanceOf(McpError.class) + .hasMessageContaining("Error invoking resource method"); + } + + @Test + public void testInvalidSyncExchangeParameter() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("invalidSyncExchangeParameter", + McpSyncServerExchange.class, ReadResourceRequest.class); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> SyncStatelessMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(createMockMcpResource())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpSyncServerExchange"); + } + + @Test + public void testInvalidAsyncExchangeParameter() throws Exception { + TestResourceProvider provider = new TestResourceProvider(); + Method method = TestResourceProvider.class.getMethod("invalidAsyncExchangeParameter", + McpAsyncServerExchange.class, ReadResourceRequest.class); + + // Should fail during callback creation due to parameter validation + assertThatThrownBy(() -> SyncStatelessMcpResourceMethodCallback.builder() + .method(method) + .bean(provider) + .resource(ResourceAdapter.asResource(createMockMcpResource())) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Method parameters must be exchange, ReadResourceRequest, String, McpMeta, or @McpProgressToken") + .hasMessageContaining("McpAsyncServerExchange"); + } + }