diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpAppResult.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpAppResult.java new file mode 100644 index 0000000000..34ff9543c4 --- /dev/null +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpAppResult.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.annotation; + +import java.util.Map; + +/** + * Convenience return type for MCP App tool methods that need to provide both model-facing + * content and widget-facing structured content. + * + * @param text plain-text content sent to the LLM in content[]; may be {@code null} when + * {@code structuredContent} is provided + * @param structuredContent map sent to the widget UI as structuredContent; may be + * {@code null} when {@code text} is provided + * @author Alexandros Pappas + */ +public record McpAppResult(String text, Map structuredContent) { + + public McpAppResult { + if (text == null && structuredContent == null) { + throw new IllegalArgumentException("At least one of text or structuredContent must be provided"); + } + } + + public static McpAppResult of(String text, Map structuredContent) { + return new McpAppResult(text, structuredContent); + } + +} diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpCsp.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpCsp.java new file mode 100644 index 0000000000..8bb4118cff --- /dev/null +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpCsp.java @@ -0,0 +1,73 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Content Security Policy configuration for MCP App tools (SEP-1865). + * + *

+ * Declares which external domains the MCP App iframe is allowed to access. All external + * network access must be declared — missing declarations result in silently blocked + * requests. + * + *

+ * Domain entries must be full origins including the scheme, for example + * {@code "https://api.example.com"}. + * + *

+ * On the wire, these are serialized into {@code _meta.ui.csp} as: + * + *

+ * {
+ *   "csp": {
+ *     "connectDomains": ["https://api.example.com"],
+ *     "resourceDomains": ["https://cdn.example.com"],
+ *     "redirectDomains": ["https://auth.example.com"]
+ *   }
+ * }
+ * 
+ * + * @author Alexandros Pappas + * @see McpTool#csp() + */ +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface McpCsp { + + /** + * Domains the iframe can make API calls to (fetch, XHR, WebSocket). + */ + String[] connectDomains() default {}; + + /** + * Domains the iframe can load static assets from (scripts, images, stylesheets). + */ + String[] resourceDomains() default {}; + + /** + * Domains the iframe can navigate or redirect to. + */ + String[] redirectDomains() default {}; + +} diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpTool.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpTool.java index 6d2c311532..c37d1526a7 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpTool.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpTool.java @@ -71,6 +71,34 @@ */ Class metaProvider() default DefaultMetaProvider.class; + /** + * The {@code ui://} resource URI for the MCP App widget associated with this tool + * (SEP-1865). When set, the tool's {@code _meta} will include {@code ui.resourceUri} + * and the flat alias {@code ui/resourceUri}. + * + *

+ * Example: {@code "ui://my-server/dashboard.html"} + */ + String resourceUri() default ""; + + /** + * Visibility of this tool per SEP-1865. Controls which consumers can see and invoke + * the tool. Default is empty (no visibility constraint — tool is visible to LLM only, + * same as {@link Visibility#MODEL}). + * + *

+ * On the wire, serialized as a JSON array: {@code ["model"]}, {@code ["app"]}, or + * {@code ["model", "app"]}. + */ + Visibility[] visibility() default {}; + + /** + * Content Security Policy for the MCP App iframe (SEP-1865). Declares which external + * domains the widget is allowed to access. Only relevant when {@link #resourceUri()} + * is set. + */ + McpCsp csp() default @McpCsp; + /** * Additional properties describing a Tool to clients. * diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/Visibility.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/Visibility.java new file mode 100644 index 0000000000..b2c7936742 --- /dev/null +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/Visibility.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.annotation; + +/** + * Defines the visibility of an MCP App tool per SEP-1865. + * + *

+ * The visibility controls which consumers can see and invoke the tool: + *

+ * + *

+ * On the wire, visibility is serialized as a JSON array of lowercase strings in + * {@code _meta.ui.visibility}, e.g. {@code ["model"]} or {@code ["app"]} or + * {@code ["model", "app"]}. + * + * @author Alexandros Pappas + * @see McpTool#visibility() + */ +public enum Visibility { + + /** + * Tool is visible to the LLM agent. + */ + MODEL("model"), + + /** + * Tool is visible to the MCP App iframe (backend tool called by UI, not the LLM). + */ + APP("app"); + + private final String wireValue; + + Visibility(String wireValue) { + this.wireValue = wireValue; + } + + /** + * Returns the lowercase wire-format string used in {@code _meta.ui.visibility}. + * @return the wire value, e.g. "model" or "app" + */ + public String getWireValue() { + return this.wireValue; + } + +} diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java index ebccd782f1..d856cc8b3a 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapter.java @@ -17,6 +17,7 @@ package org.springframework.ai.mcp.annotation.adapter; import java.util.List; +import java.util.Locale; import io.modelcontextprotocol.spec.McpSchema; @@ -29,6 +30,11 @@ * {@link McpSchema.ResourceTemplate} instances from annotation metadata, including URI, * name, description, MIME type, annotations, and optional {@code _meta} fields. * + *

+ * Enforces SEP-1865 (MCP Apps) constraints: resources with a {@code ui://} URI must use + * the MIME type {@code text/html;profile=mcp-app}, and resources with that MIME type must + * use a {@code ui://} URI. + * * @author Christian Tzolov * @author Alexandros Pappas * @author Vadzim Shurmialiou @@ -36,6 +42,37 @@ */ public final class ResourceAdapter { + private static final String MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; + + private static final String UI_URI_SCHEME = "ui://"; + + private static void validateMcpAppResource(String uri, String mimeType) { + boolean uiUri = isUiUri(uri); + boolean mcpAppMimeType = isMcpAppMimeType(mimeType); + if (uiUri && !mcpAppMimeType) { + throw new IllegalArgumentException( + "Resource with ui:// URI must use MIME type 'text/html;profile=mcp-app', but got: " + mimeType); + } + if (mcpAppMimeType && !uiUri) { + throw new IllegalArgumentException( + "Resource with MIME type 'text/html;profile=mcp-app' must use a ui:// URI, but got: " + uri); + } + } + + private static boolean isUiUri(String uri) { + // URI schemes are case-insensitive (RFC 3986) + return uri != null && uri.regionMatches(true, 0, UI_URI_SCHEME, 0, UI_URI_SCHEME.length()); + } + + private static boolean isMcpAppMimeType(String mimeType) { + if (mimeType == null) { + return false; + } + // MIME types are case-insensitive and allow whitespace around parameters + String normalized = mimeType.replace(" ", "").replace("\t", "").toLowerCase(Locale.ROOT); + return MCP_APP_MIME_TYPE.equals(normalized); + } + private ResourceAdapter() { } @@ -46,6 +83,8 @@ public static McpSchema.Resource asResource(McpResource mcpResourceAnnotation) { } var meta = MetaUtils.getMeta(mcpResourceAnnotation.metaProvider()); + validateMcpAppResource(mcpResourceAnnotation.uri(), mcpResourceAnnotation.mimeType()); + var resourceBuilder = McpSchema.Resource.builder(mcpResourceAnnotation.uri(), name) .title(mcpResourceAnnotation.title()) .description(mcpResourceAnnotation.description()) @@ -73,6 +112,8 @@ public static McpSchema.ResourceTemplate asResourceTemplate(McpResource mcpResou } var meta = MetaUtils.getMeta(mcpResource.metaProvider()); + validateMcpAppResource(mcpResource.uri(), mcpResource.mimeType()); + return McpSchema.ResourceTemplate.builder(mcpResource.uri(), name) .description(mcpResource.description()) .mimeType(mcpResource.mimeType()) diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/MetaUtils.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/MetaUtils.java index db4c5aed9b..283be0b149 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/MetaUtils.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/common/MetaUtils.java @@ -18,9 +18,15 @@ import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.ai.mcp.annotation.McpCsp; +import org.springframework.ai.mcp.annotation.Visibility; import org.springframework.ai.mcp.annotation.context.MetaProvider; /** @@ -103,4 +109,107 @@ private static Constructor getConstructor(Class + * Produces the wire format:

+	 * {
+	 *   "ui": { "resourceUri": "ui://...", "visibility": ["model"], "csp": {...} },
+	 *   "ui/resourceUri": "ui://..."
+	 * }
+	 * 
+ * @param resourceUri the {@code ui://} resource URI; if blank, returns {@code null} + * @param visibility the visibility array from the annotation + * @param csp the CSP annotation, or {@code null} + * @return the {@code _meta} map, or {@code null} if no SEP-1865 fields are set + */ + public static Map buildUiMeta(String resourceUri, Visibility[] visibility, McpCsp csp) { + if (resourceUri == null || resourceUri.isBlank()) { + return null; + } + + var uiMap = new LinkedHashMap(); + uiMap.put("resourceUri", resourceUri); + + if (visibility != null && visibility.length > 0) { + uiMap.put("visibility", + Arrays.stream(visibility).map(Visibility::getWireValue).collect(Collectors.toList())); + } + + if (csp != null) { + var cspMap = new LinkedHashMap(); + if (csp.connectDomains().length > 0) { + cspMap.put("connectDomains", List.of(csp.connectDomains())); + } + if (csp.resourceDomains().length > 0) { + cspMap.put("resourceDomains", List.of(csp.resourceDomains())); + } + if (csp.redirectDomains().length > 0) { + cspMap.put("redirectDomains", List.of(csp.redirectDomains())); + } + if (!cspMap.isEmpty()) { + uiMap.put("csp", cspMap); + } + } + + var meta = new LinkedHashMap(); + meta.put("ui", uiMap); + meta.put("ui/resourceUri", resourceUri); + return meta; + } + + /** + * Merge two metadata maps with provider-wins-on-conflict semantics. + *

+ * Top-level keys from both maps are combined. For the {@code "ui"} key specifically, + * the nested maps are deep-merged so annotation-derived fields (like + * {@code visibility}) survive even when the provider also sets {@code "ui"}. Provider + * values win for any key present in both. + * @param providerMeta metadata from {@link MetaProvider}, may be {@code null} + * @param annotationMeta metadata built from annotation fields, may be {@code null} + * @return the merged map, or {@code null} if both inputs are {@code null} + */ + @SuppressWarnings("unchecked") + public static Map mergeMeta(Map providerMeta, Map annotationMeta) { + if (providerMeta == null && annotationMeta == null) { + return null; + } + if (providerMeta == null) { + return annotationMeta; + } + if (annotationMeta == null) { + return providerMeta; + } + + var merged = new LinkedHashMap(); + merged.putAll(annotationMeta); + + for (var entry : providerMeta.entrySet()) { + String key = entry.getKey(); + Object providerValue = entry.getValue(); + Object existingValue = merged.get(key); + + if ("ui".equals(key) && providerValue instanceof Map && existingValue instanceof Map) { + var deepMerged = new LinkedHashMap(); + deepMerged.putAll((Map) existingValue); + deepMerged.putAll((Map) providerValue); + merged.put(key, deepMerged); + } + else { + merged.put(key, providerValue); + } + } + + // Re-derive the flat "ui/resourceUri" alias from the merged nested value so the + // two never diverge when a MetaProvider overrides ui.resourceUri. + if (merged.containsKey("ui/resourceUri") && merged.get("ui") instanceof Map uiMap) { + Object mergedResourceUri = uiMap.get("resourceUri"); + if (mergedResourceUri != null) { + merged.put("ui/resourceUri", mergedResourceUri); + } + } + + return merged; + } + } diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractMcpToolMethodCallback.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractMcpToolMethodCallback.java index 7953b1846d..954b878a66 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractMcpToolMethodCallback.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/method/tool/AbstractMcpToolMethodCallback.java @@ -28,6 +28,7 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import org.jspecify.annotations.Nullable; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpMeta; import org.springframework.ai.mcp.annotation.McpProgressToken; import org.springframework.ai.mcp.annotation.McpTool; @@ -175,6 +176,24 @@ protected CallToolResult convertValueToCallToolResult(Object result) { return CallToolResult.builder().addTextContent(jsonHelper.toJson("Done")).build(); } + if (result == null) { + return CallToolResult.builder().addTextContent("null").build(); + } + + // McpAppResult splits text into content[] and structuredContent. Must be + // handled before the STRUCTURED branch so the container record is never + // re-serialized whole into structuredContent. + if (result instanceof McpAppResult appResult) { + var builder = CallToolResult.builder(); + if (appResult.text() != null) { + builder.addTextContent(appResult.text()); + } + if (appResult.structuredContent() != null) { + builder.structuredContent(appResult.structuredContent()); + } + return builder.build(); + } + if (this.returnMode == ReturnMode.STRUCTURED) { String jsonOutput = jsonHelper.toJson(result); Object structuredOutput = jsonHelper.fromJson(jsonOutput, Object.class); @@ -182,9 +201,6 @@ protected CallToolResult convertValueToCallToolResult(Object result) { } // Default to text output - if (result == null) { - return CallToolResult.builder().addTextContent("null").build(); - } // For string results in TEXT mode, return the string directly without JSON // serialization diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java index 0e596a3cff..90be0baef1 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProvider.java @@ -32,6 +32,7 @@ import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; @@ -83,7 +84,10 @@ public List getToolSpecifications() { String inputSchema = McpJsonSchemaGenerator.generateForMethodInput(mcpToolMethod); - var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var providerMeta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var annotationMeta = MetaUtils.buildUiMeta(toolJavaAnnotation.resourceUri(), + toolJavaAnnotation.visibility(), toolJavaAnnotation.csp()); + var meta = MetaUtils.mergeMeta(providerMeta, annotationMeta); var toolBuilder = McpSchema.Tool.builder() .name(toolName) @@ -118,7 +122,7 @@ public List getToolSpecifications() { // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, - // CallToolResult, simple value types (String, etc.) + // CallToolResult, McpAppResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. if (toolJavaAnnotation.generateOutputSchema() && !ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod) @@ -127,7 +131,8 @@ public List getToolSpecifications() { ReactiveUtils.getReactiveReturnTypeArgument(mcpToolMethod).ifPresent(typeArgument -> { Class methodReturnType = typeArgument instanceof Class ? (Class) typeArgument : null; - if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType) + if (methodReturnType != McpAppResult.class + && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) && !ClassUtils.isSimpleValueType(methodReturnType)) { toolBuilder.outputSchema(this.getJsonMapper(), McpJsonSchemaGenerator.generateFromClass((Class) typeArgument)); diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java index f9b06c6c50..46b0661259 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProvider.java @@ -32,6 +32,7 @@ import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; @@ -88,7 +89,10 @@ public List getToolSpecifications() { String inputSchema = McpJsonSchemaGenerator.generateForMethodInput(mcpToolMethod); - var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var providerMeta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var annotationMeta = MetaUtils.buildUiMeta(toolJavaAnnotation.resourceUri(), + toolJavaAnnotation.visibility(), toolJavaAnnotation.csp()); + var meta = MetaUtils.mergeMeta(providerMeta, annotationMeta); var toolBuilder = McpSchema.Tool.builder() .name(toolName) @@ -123,7 +127,7 @@ public List getToolSpecifications() { // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, - // CallToolResult, simple value types (String, etc.) + // CallToolResult, McpAppResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. if (toolJavaAnnotation.generateOutputSchema() && !ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod) @@ -132,7 +136,8 @@ public List getToolSpecifications() { ReactiveUtils.getReactiveReturnTypeArgument(mcpToolMethod).ifPresent(typeArgument -> { Class methodReturnType = typeArgument instanceof Class ? (Class) typeArgument : null; - if (!ClassUtils.isPrimitiveOrWrapper(methodReturnType) + if (methodReturnType != McpAppResult.class + && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) && !ClassUtils.isSimpleValueType(methodReturnType)) { toolBuilder.outputSchema(this.getJsonMapper(), McpJsonSchemaGenerator.generateFromType(typeArgument)); diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java index 3b92b16a61..296aaef9c1 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProvider.java @@ -31,6 +31,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; @@ -81,7 +82,10 @@ public List getToolSpecifications() { String inputSchema = McpJsonSchemaGenerator.generateForMethodInput(mcpToolMethod); - var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var providerMeta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var annotationMeta = MetaUtils.buildUiMeta(toolJavaAnnotation.resourceUri(), + toolJavaAnnotation.visibility(), toolJavaAnnotation.csp()); + var meta = MetaUtils.mergeMeta(providerMeta, annotationMeta); var toolBuilder = McpSchema.Tool.builder() .name(toolName) @@ -116,12 +120,13 @@ public List getToolSpecifications() { // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, - // CallToolResult, simple value types (String, etc.) + // CallToolResult, McpAppResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. Class methodReturnType = mcpToolMethod.getReturnType(); if (toolJavaAnnotation.generateOutputSchema() && methodReturnType != null - && methodReturnType != CallToolResult.class && methodReturnType != Void.class - && methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) + && methodReturnType != CallToolResult.class && methodReturnType != McpAppResult.class + && methodReturnType != Void.class && methodReturnType != void.class + && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) && !ClassUtils.isSimpleValueType(methodReturnType)) { toolBuilder.outputSchema(this.getJsonMapper(), diff --git a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProvider.java b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProvider.java index 0cb409b514..2ae990ce28 100644 --- a/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProvider.java +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProvider.java @@ -31,6 +31,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.common.McpPredicates; import org.springframework.ai.mcp.annotation.common.MetaUtils; @@ -85,7 +86,10 @@ public List getToolSpecifications() { String inputSchema = McpJsonSchemaGenerator.generateForMethodInput(mcpToolMethod); - var meta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var providerMeta = MetaUtils.getMeta(toolJavaAnnotation.metaProvider()); + var annotationMeta = MetaUtils.buildUiMeta(toolJavaAnnotation.resourceUri(), + toolJavaAnnotation.visibility(), toolJavaAnnotation.csp()); + var meta = MetaUtils.mergeMeta(providerMeta, annotationMeta); var toolBuilder = McpSchema.Tool.builder() .name(toolName) @@ -120,12 +124,13 @@ public List getToolSpecifications() { // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, - // CallToolResult, simple value types (String, etc.) + // CallToolResult, McpAppResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. Class methodReturnType = mcpToolMethod.getReturnType(); if (toolJavaAnnotation.generateOutputSchema() && methodReturnType != null - && methodReturnType != CallToolResult.class && methodReturnType != Void.class - && methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) + && methodReturnType != CallToolResult.class && methodReturnType != McpAppResult.class + && methodReturnType != Void.class && methodReturnType != void.class + && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) && !ClassUtils.isSimpleValueType(methodReturnType)) { toolBuilder.outputSchema(this.getJsonMapper(), diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/McpAppResultTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/McpAppResultTests.java new file mode 100644 index 0000000000..7b1543e7b4 --- /dev/null +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/McpAppResultTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.annotation; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class McpAppResultTests { + + @Test + void testConstructor() { + var structured = Map.of("key", "value"); + var result = new McpAppResult("hello", structured); + assertThat(result.text()).isEqualTo("hello"); + assertThat(result.structuredContent()).isEqualTo(structured); + } + + @Test + void testStaticFactory() { + var structured = Map.of("count", 42); + var result = McpAppResult.of("done", structured); + assertThat(result.text()).isEqualTo("done"); + assertThat(result.structuredContent()).containsEntry("count", 42); + } + + @Test + void testNullTextAllowed() { + var result = McpAppResult.of(null, Map.of("key", "value")); + assertThat(result.text()).isNull(); + assertThat(result.structuredContent()).containsEntry("key", "value"); + } + + @Test + void testNullStructuredContentAllowed() { + var result = McpAppResult.of("text only", null); + assertThat(result.text()).isEqualTo("text only"); + assertThat(result.structuredContent()).isNull(); + } + + @Test + void testBothNullThrows() { + assertThatIllegalArgumentException().isThrownBy(() -> McpAppResult.of(null, null)) + .withMessageContaining("At least one of text or structuredContent"); + } + +} diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapterTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapterTests.java new file mode 100644 index 0000000000..8cdf6f04c8 --- /dev/null +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapterTests.java @@ -0,0 +1,219 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp.annotation.adapter; + +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.mcp.annotation.McpResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for SEP-1865 URI / MIME type validation in {@link ResourceAdapter}. + * + * @author Alexandros Pappas + */ +class ResourceAdapterTests { + + private static final String MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; + + // --------------------------------------------------------------------------- + // asResource — valid cases + // --------------------------------------------------------------------------- + + @Test + void asResource_uiUriWithCorrectMimeType_succeeds() { + McpResource annotation = mockResource("ui://my-server/app.html", MCP_APP_MIME_TYPE); + McpSchema.Resource resource = ResourceAdapter.asResource(annotation); + assertThat(resource.uri()).isEqualTo("ui://my-server/app.html"); + assertThat(resource.mimeType()).isEqualTo(MCP_APP_MIME_TYPE); + } + + @Test + void asResource_regularUriWithRegularMimeType_succeeds() { + McpResource annotation = mockResource("file://data/doc.txt", "text/plain"); + McpSchema.Resource resource = ResourceAdapter.asResource(annotation); + assertThat(resource.uri()).isEqualTo("file://data/doc.txt"); + assertThat(resource.mimeType()).isEqualTo("text/plain"); + } + + @Test + void asResource_regularUriWithNullMimeType_succeeds() { + McpResource annotation = mockResource("https://example.com/data", ""); + McpSchema.Resource resource = ResourceAdapter.asResource(annotation); + assertThat(resource.uri()).isEqualTo("https://example.com/data"); + } + + @Test + void asResource_uiUriWithMimeTypeContainingWhitespace_succeeds() { + McpResource annotation = mockResource("ui://my-server/app.html", "text/html; profile=mcp-app"); + McpSchema.Resource resource = ResourceAdapter.asResource(annotation); + assertThat(resource.mimeType()).isEqualTo("text/html; profile=mcp-app"); + } + + @Test + void asResource_uiUriWithMixedCaseMimeType_succeeds() { + McpResource annotation = mockResource("ui://my-server/app.html", "Text/HTML;profile=mcp-app"); + McpSchema.Resource resource = ResourceAdapter.asResource(annotation); + assertThat(resource.mimeType()).isEqualTo("Text/HTML;profile=mcp-app"); + } + + @Test + void asResource_upperCaseUiSchemeWithWrongMimeType_throwsIllegalArgumentException() { + McpResource annotation = mockResource("UI://my-server/app.html", "text/plain"); + assertThatIllegalArgumentException().isThrownBy(() -> ResourceAdapter.asResource(annotation)) + .withMessageContaining("ui:// URI must use MIME type 'text/html;profile=mcp-app'"); + } + + // --------------------------------------------------------------------------- + // asResource — invalid cases + // --------------------------------------------------------------------------- + + @Test + void asResource_uiUriWithWrongMimeType_throwsIllegalArgumentException() { + McpResource annotation = mockResource("ui://my-server/app.html", "text/plain"); + assertThatIllegalArgumentException().isThrownBy(() -> ResourceAdapter.asResource(annotation)) + .withMessageContaining("ui:// URI must use MIME type 'text/html;profile=mcp-app'") + .withMessageContaining("text/plain"); + } + + @Test + void asResource_uiUriWithEmptyMimeType_throwsIllegalArgumentException() { + McpResource annotation = mockResource("ui://my-server/app.html", ""); + assertThatIllegalArgumentException().isThrownBy(() -> ResourceAdapter.asResource(annotation)) + .withMessageContaining("ui:// URI must use MIME type 'text/html;profile=mcp-app'"); + } + + @Test + void asResource_mcpAppMimeTypeWithNonUiUri_throwsIllegalArgumentException() { + McpResource annotation = mockResource("https://example.com/app.html", MCP_APP_MIME_TYPE); + assertThatIllegalArgumentException().isThrownBy(() -> ResourceAdapter.asResource(annotation)) + .withMessageContaining("MIME type 'text/html;profile=mcp-app' must use a ui:// URI") + .withMessageContaining("https://example.com/app.html"); + } + + // --------------------------------------------------------------------------- + // asResourceTemplate — valid cases + // --------------------------------------------------------------------------- + + @Test + void asResourceTemplate_uiUriWithCorrectMimeType_succeeds() { + McpResource annotation = mockResource("ui://my-server/{id}.html", MCP_APP_MIME_TYPE); + McpSchema.ResourceTemplate template = ResourceAdapter.asResourceTemplate(annotation); + assertThat(template.uriTemplate()).isEqualTo("ui://my-server/{id}.html"); + assertThat(template.mimeType()).isEqualTo(MCP_APP_MIME_TYPE); + } + + @Test + void asResourceTemplate_regularUriWithRegularMimeType_succeeds() { + McpResource annotation = mockResource("file://data/{name}.txt", "text/plain"); + McpSchema.ResourceTemplate template = ResourceAdapter.asResourceTemplate(annotation); + assertThat(template.uriTemplate()).isEqualTo("file://data/{name}.txt"); + } + + // --------------------------------------------------------------------------- + // asResourceTemplate — invalid cases + // --------------------------------------------------------------------------- + + @Test + void asResourceTemplate_uiUriWithWrongMimeType_throwsIllegalArgumentException() { + McpResource annotation = mockResource("ui://my-server/{id}.html", "application/json"); + assertThatIllegalArgumentException().isThrownBy(() -> ResourceAdapter.asResourceTemplate(annotation)) + .withMessageContaining("ui:// URI must use MIME type 'text/html;profile=mcp-app'") + .withMessageContaining("application/json"); + } + + @Test + void asResourceTemplate_mcpAppMimeTypeWithNonUiUri_throwsIllegalArgumentException() { + McpResource annotation = mockResource("https://example.com/{page}.html", MCP_APP_MIME_TYPE); + assertThatIllegalArgumentException().isThrownBy(() -> ResourceAdapter.asResourceTemplate(annotation)) + .withMessageContaining("MIME type 'text/html;profile=mcp-app' must use a ui:// URI") + .withMessageContaining("https://example.com/{page}.html"); + } + + // --------------------------------------------------------------------------- + // Helper + // --------------------------------------------------------------------------- + + private McpResource mockResource(String uri, String mimeType) { + return new McpResource() { + @Override + public Class annotationType() { + return McpResource.class; + } + + @Override + public String uri() { + return uri; + } + + @Override + public String name() { + return "test-resource"; + } + + @Override + public String title() { + return ""; + } + + @Override + public String description() { + return ""; + } + + @Override + public String mimeType() { + return mimeType; + } + + @Override + public McpAnnotations annotations() { + return new McpAnnotations() { + @Override + public Class annotationType() { + return McpAnnotations.class; + } + + @Override + public io.modelcontextprotocol.spec.McpSchema.Role[] audience() { + return new io.modelcontextprotocol.spec.McpSchema.Role[] { + io.modelcontextprotocol.spec.McpSchema.Role.USER }; + } + + @Override + public String lastModified() { + return ""; + } + + @Override + public double priority() { + return 0.5; + } + }; + } + + @Override + public Class metaProvider() { + return org.springframework.ai.mcp.annotation.context.DefaultMetaProvider.class; + } + }; + } + +} diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/common/MetaUtilsTest.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/common/MetaUtilsTest.java index 5a643ba419..569546b769 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/common/MetaUtilsTest.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/common/MetaUtilsTest.java @@ -16,15 +16,20 @@ package org.springframework.ai.mcp.annotation.common; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.ai.mcp.annotation.McpCsp; +import org.springframework.ai.mcp.annotation.Visibility; import org.springframework.ai.mcp.annotation.context.DefaultMetaProvider; import org.springframework.ai.mcp.annotation.context.MetaProvider; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; final class MetaUtilsTest { @@ -78,6 +83,123 @@ void testMetaProviderClassIsNullReturnsNull() { assertThat(actual).isNull(); } + @Test + void testBuildUiMetaWithResourceUri() { + Map uiMeta = MetaUtils.buildUiMeta("ui://test/view.html", new Visibility[0], null); + + assertThat(uiMeta).isNotNull(); + assertThat(uiMeta).containsKey("ui"); + assertThat(uiMeta).containsKey("ui/resourceUri"); + assertThat(uiMeta.get("ui/resourceUri")).isEqualTo("ui://test/view.html"); + + @SuppressWarnings("unchecked") + Map ui = (Map) uiMeta.get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test/view.html"); + } + + @Test + void testBuildUiMetaWithVisibility() { + Map uiMeta = MetaUtils.buildUiMeta("ui://test/view.html", new Visibility[] { Visibility.APP }, + null); + + @SuppressWarnings("unchecked") + Map ui = (Map) uiMeta.get("ui"); + assertThat(ui.get("visibility")).isEqualTo(List.of("app")); + } + + @Test + void testBuildUiMetaWithMultipleVisibility() { + Map uiMeta = MetaUtils.buildUiMeta("ui://test/view.html", + new Visibility[] { Visibility.MODEL, Visibility.APP }, null); + + @SuppressWarnings("unchecked") + Map ui = (Map) uiMeta.get("ui"); + assertThat(ui.get("visibility")).isEqualTo(List.of("model", "app")); + } + + @Test + void testBuildUiMetaWithCsp() { + McpCsp csp = mock(McpCsp.class); + when(csp.connectDomains()).thenReturn(new String[] { "https://api.example.com" }); + when(csp.resourceDomains()).thenReturn(new String[] { "https://cdn.example.com" }); + when(csp.redirectDomains()).thenReturn(new String[] {}); + + Map uiMeta = MetaUtils.buildUiMeta("ui://test/view.html", new Visibility[0], csp); + + @SuppressWarnings("unchecked") + Map ui = (Map) uiMeta.get("ui"); + @SuppressWarnings("unchecked") + Map cspMap = (Map) ui.get("csp"); + assertThat(cspMap.get("connectDomains")).isEqualTo(List.of("https://api.example.com")); + assertThat(cspMap.get("resourceDomains")).isEqualTo(List.of("https://cdn.example.com")); + assertThat(cspMap).doesNotContainKey("redirectDomains"); + } + + @Test + void testBuildUiMetaWithEmptyResourceUri() { + Map uiMeta = MetaUtils.buildUiMeta("", new Visibility[0], null); + assertThat(uiMeta).isNull(); + } + + @Test + void testBuildUiMetaWithBlankResourceUri() { + Map uiMeta = MetaUtils.buildUiMeta(" ", new Visibility[0], null); + assertThat(uiMeta).isNull(); + } + + @Test + void testMergeMetaProviderWinsOnConflict() { + Map providerMeta = Map.of("ui", + Map.of("resourceUri", "ui://provider/override.html", "custom", "value"), "other", "data"); + + Map annotationMeta = Map.of("ui", + Map.of("resourceUri", "ui://annotation/view.html", "visibility", List.of("app")), "ui/resourceUri", + "ui://annotation/view.html"); + + Map merged = MetaUtils.mergeMeta(providerMeta, annotationMeta); + + assertThat(merged).containsKey("other"); + @SuppressWarnings("unchecked") + Map ui = (Map) merged.get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://provider/override.html"); + assertThat(ui.get("custom")).isEqualTo("value"); + assertThat(ui.get("visibility")).isEqualTo(List.of("app")); + } + + @Test + void testMergeMetaKeepsFlatAliasConsistentWithNestedUi() { + Map providerMeta = Map.of("ui", Map.of("resourceUri", "ui://provider/override.html")); + Map annotationMeta = MetaUtils.buildUiMeta("ui://annotation/view.html", new Visibility[0], + null); + + Map merged = MetaUtils.mergeMeta(providerMeta, annotationMeta); + + @SuppressWarnings("unchecked") + Map ui = (Map) merged.get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://provider/override.html"); + assertThat(merged.get("ui/resourceUri")).isEqualTo("ui://provider/override.html"); + } + + @Test + void testMergeMetaBothNull() { + Map merged = MetaUtils.mergeMeta(null, null); + assertThat(merged).isNull(); + } + + @Test + void testMergeMetaOnlyProvider() { + Map providerMeta = Map.of("key", "value"); + Map merged = MetaUtils.mergeMeta(providerMeta, null); + assertThat(merged).isEqualTo(providerMeta); + } + + @Test + void testMergeMetaOnlyAnnotation() { + Map annotationMeta = Map.of("ui", Map.of("resourceUri", "ui://test/view.html")); + Map merged = MetaUtils.mergeMeta(null, annotationMeta); + assertThat(merged).isEqualTo(annotationMeta); + } + static class MetaProviderWithDefaultConstructor implements MetaProvider { @Override diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java index dd30e28710..e7d9f053ca 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncMcpToolProviderTests.java @@ -34,7 +34,9 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.Visibility; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -1021,4 +1023,65 @@ public Mono outputSchemaTool(String input) { assertThat(toolSpec.tool().outputSchema()).isNotNull(); } + @Test + void testToolReturningMcpAppResultSplitsTextAndStructuredContent() { + class AppResultTool { + + @McpTool(name = "app-result-tool", description = "Tool returning McpAppResult", + resourceUri = "ui://test-server/app.html", generateOutputSchema = true) + public Mono appResultTool(String input) { + return Mono.just(McpAppResult.of("text for the LLM", Map.of("key", "value"))); + } + + } + + AppResultTool toolObject = new AppResultTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + var toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // McpAppResult is a wire-format container: no output schema must be generated + assertThat(toolSpecs.get(0).tool().outputSchema()).isNull(); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("app-result-tool", Map.of("input", "hello")); + + StepVerifier.create(toolSpecs.get(0).callHandler().apply(exchange, request)).assertNext(result -> { + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("text for the LLM"); + assertThat(result.structuredContent()).isEqualTo(Map.of("key", "value")); + }).verifyComplete(); + } + + @Test + void testToolWithResourceUriAddsUiMeta() { + class ResourceUriTool { + + @McpTool(name = "uri-tool", description = "Tool with resourceUri", + resourceUri = "ui://test-server/app.html", visibility = Visibility.APP) + public Mono uriTool(String input) { + return Mono.just("result: " + input); + } + + } + + ResourceUriTool toolObject = new ResourceUriTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + var toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + var tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + assertThat(tool.meta()).containsKey("ui"); + assertThat(tool.meta()).containsKey("ui/resourceUri"); + assertThat(tool.meta().get("ui/resourceUri")).isEqualTo("ui://test-server/app.html"); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test-server/app.html"); + assertThat(ui.get("visibility")).isEqualTo(List.of("app")); + } + } diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java index bb43441b65..b130ce3303 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/AsyncStatelessMcpToolProviderTests.java @@ -30,7 +30,9 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.Visibility; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -1003,4 +1005,65 @@ public Mono outputSchemaTool(String input) { assertThat(toolSpec.tool().outputSchema()).isNotNull(); } + @Test + void testToolReturningMcpAppResultSplitsTextAndStructuredContent() { + class AppResultTool { + + @McpTool(name = "app-result-tool", description = "Tool returning McpAppResult", + resourceUri = "ui://test-server/app.html", generateOutputSchema = true) + public Mono appResultTool(String input) { + return Mono.just(McpAppResult.of("text for the LLM", Map.of("key", "value"))); + } + + } + + AppResultTool toolObject = new AppResultTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + var toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // McpAppResult is a wire-format container: no output schema must be generated + assertThat(toolSpecs.get(0).tool().outputSchema()).isNull(); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("app-result-tool", Map.of("input", "hello")); + + StepVerifier.create(toolSpecs.get(0).callHandler().apply(context, request)).assertNext(result -> { + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("text for the LLM"); + assertThat(result.structuredContent()).isEqualTo(Map.of("key", "value")); + }).verifyComplete(); + } + + @Test + void testToolWithResourceUriAddsUiMeta() { + class ResourceUriTool { + + @McpTool(name = "uri-tool", description = "Tool with resourceUri", + resourceUri = "ui://test-server/app.html", visibility = Visibility.APP) + public Mono uriTool(String input) { + return Mono.just("result: " + input); + } + + } + + ResourceUriTool toolObject = new ResourceUriTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + var toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + var tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + assertThat(tool.meta()).containsKey("ui"); + assertThat(tool.meta()).containsKey("ui/resourceUri"); + assertThat(tool.meta().get("ui/resourceUri")).isEqualTo("ui://test-server/app.html"); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test-server/app.html"); + assertThat(ui.get("visibility")).isEqualTo(List.of("app")); + } + } diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProviderTests.java index e910f20aba..c542567da5 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProviderTests.java @@ -34,7 +34,10 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import org.springframework.ai.mcp.annotation.McpAppResult; +import org.springframework.ai.mcp.annotation.McpCsp; import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.Visibility; import org.springframework.ai.mcp.annotation.context.MetaProvider; import static org.assertj.core.api.Assertions.assertThat; @@ -917,6 +920,219 @@ public String onlyRequestTool(CallToolRequest request) { assertThat(toolSpec.tool().inputSchema()).isNotNull(); } + @Test + void testToolWithResourceUri() { + class ResourceUriTool { + + @McpTool(name = "uri-tool", description = "Tool with resourceUri", + resourceUri = "ui://test-server/app.html") + public String uriTool(String input) { + return "result: " + input; + } + + } + + ResourceUriTool toolObject = new ResourceUriTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + McpSchema.Tool tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + assertThat(tool.meta()).containsKey("ui"); + assertThat(tool.meta()).containsKey("ui/resourceUri"); + assertThat(tool.meta().get("ui/resourceUri")).isEqualTo("ui://test-server/app.html"); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test-server/app.html"); + } + + @Test + void testToolWithResourceUriAndVisibility() { + class ResourceUriVisibilityTool { + + @McpTool(name = "vis-tool", description = "Tool with resourceUri and visibility", + resourceUri = "ui://test-server/app.html", visibility = Visibility.APP) + public String visTool(String input) { + return "result: " + input; + } + + } + + ResourceUriVisibilityTool toolObject = new ResourceUriVisibilityTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + McpSchema.Tool tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + assertThat(tool.meta()).containsKey("ui/resourceUri"); + assertThat(tool.meta().get("ui/resourceUri")).isEqualTo("ui://test-server/app.html"); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test-server/app.html"); + assertThat(ui.get("visibility")).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List visibility = (List) ui.get("visibility"); + assertThat(visibility).containsExactly("app"); + } + + @Test + void testToolWithResourceUriAndCsp() { + class ResourceUriCspTool { + + @McpTool(name = "csp-tool", description = "Tool with resourceUri and CSP", + resourceUri = "ui://test-server/app.html", + csp = @McpCsp(connectDomains = "https://api.example.com")) + public String cspTool(String input) { + return "result: " + input; + } + + } + + ResourceUriCspTool toolObject = new ResourceUriCspTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + McpSchema.Tool tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test-server/app.html"); + assertThat(ui).containsKey("csp"); + + @SuppressWarnings("unchecked") + Map csp = (Map) ui.get("csp"); + assertThat(csp.get("connectDomains")).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List connectDomains = (List) csp.get("connectDomains"); + assertThat(connectDomains).containsExactly("https://api.example.com"); + } + + @Test + void testToolWithResourceUriAndMetaProviderMerge() { + class MergeTool { + + @McpTool(name = "merge-tool", description = "Tool with merged meta", + resourceUri = "ui://test-server/app.html", visibility = Visibility.APP, + metaProvider = UiMetaProvider.class) + public String mergeTool(String input) { + return "result: " + input; + } + + } + + MergeTool toolObject = new MergeTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + McpSchema.Tool tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + // Provider wins on conflict: UiMetaProvider overrides the annotation's + // resourceUri ("ui://test-server/app.html" -> "ui://test/view.html") + assertThat(ui.get("resourceUri")).isEqualTo("ui://test/view.html"); + // Provider wins on conflict: provider sets ["model", "app"], annotation set + // ["app"] + @SuppressWarnings("unchecked") + List mergedVisibility = (List) ui.get("visibility"); + assertThat(mergedVisibility).containsExactly("model", "app"); + } + + @Test + void testToolWithNoResourceUriKeepsBehavior() { + class PlainTool { + + @McpTool(name = "plain-tool", description = "Plain tool, no resourceUri") + public String plainTool(String input) { + return "result: " + input; + } + + } + + PlainTool toolObject = new PlainTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().meta()).isNull(); + } + + @Test + void testToolReturningMcpAppResultSplitsTextAndStructuredContent() { + class AppResultTool { + + @McpTool(name = "app-result-tool", description = "Tool returning McpAppResult", + resourceUri = "ui://test-server/app.html", generateOutputSchema = true) + public McpAppResult appResultTool(String input) { + return McpAppResult.of("text for the LLM", Map.of("key", "value")); + } + + } + + AppResultTool toolObject = new AppResultTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + McpSchema.Tool tool = toolSpecs.get(0).tool(); + // McpAppResult is a wire-format container: no output schema must be generated + assertThat(tool.outputSchema()).isNull(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("app-result-tool", Map.of("input", "hello")); + CallToolResult result = toolSpecs.get(0).callHandler().apply(exchange, request); + + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("text for the LLM"); + assertThat(result.structuredContent()).isEqualTo(Map.of("key", "value")); + } + + @Test + void testToolReturningMcpAppResultWithExplicitOutputSchemaGenerationDisabled() { + class AppResultSchemaDisabledTool { + + @McpTool(name = "app-result-schema-tool", description = "McpAppResult with generateOutputSchema true", + resourceUri = "ui://test-server/app.html", generateOutputSchema = true) + public McpAppResult appResultTool(String input) { + return McpAppResult.of("text for the LLM", Map.of("key", "value")); + } + + } + + AppResultSchemaDisabledTool toolObject = new AppResultSchemaDisabledTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + McpSchema.Tool tool = toolSpecs.get(0).tool(); + // Even with generateOutputSchema=true, McpAppResult must be excluded from schema + // generation — it's a wire-format container, not a structured data output. + assertThat(tool.outputSchema()).isNull(); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("app-result-schema-tool", Map.of("input", "hello")); + CallToolResult result = toolSpecs.get(0).callHandler().apply(exchange, request); + + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("text for the LLM"); + assertThat(result.structuredContent()).isEqualTo(Map.of("key", "value")); + } + public static class UiMetaProvider implements MetaProvider { @Override diff --git a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProviderTests.java index dfa8908d0b..4f57f5ee5f 100644 --- a/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProviderTests.java +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncStatelessMcpToolProviderTests.java @@ -32,7 +32,9 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import org.springframework.ai.mcp.annotation.McpAppResult; import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.Visibility; import org.springframework.ai.util.JsonHelper; import static org.assertj.core.api.Assertions.assertThat; @@ -960,4 +962,65 @@ public String onlyContextTool(McpTransportContext context) { assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Only context tool executed"); } + @Test + void testToolReturningMcpAppResultSplitsTextAndStructuredContent() { + class AppResultTool { + + @McpTool(name = "app-result-tool", description = "Tool returning McpAppResult", + resourceUri = "ui://test-server/app.html", generateOutputSchema = true) + public McpAppResult appResultTool(String input) { + return McpAppResult.of("text for the LLM", Map.of("key", "value")); + } + + } + + AppResultTool toolObject = new AppResultTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + var toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // McpAppResult is a wire-format container: no output schema must be generated + assertThat(toolSpecs.get(0).tool().outputSchema()).isNull(); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("app-result-tool", Map.of("input", "hello")); + + CallToolResult result = toolSpecs.get(0).callHandler().apply(context, request); + + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("text for the LLM"); + assertThat(result.structuredContent()).isEqualTo(Map.of("key", "value")); + } + + @Test + void testToolWithResourceUriAddsUiMeta() { + class ResourceUriTool { + + @McpTool(name = "uri-tool", description = "Tool with resourceUri", + resourceUri = "ui://test-server/app.html", visibility = Visibility.APP) + public String uriTool(String input) { + return "result: " + input; + } + + } + + ResourceUriTool toolObject = new ResourceUriTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + var toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + var tool = toolSpecs.get(0).tool(); + assertThat(tool.meta()).isNotNull(); + assertThat(tool.meta()).containsKey("ui"); + assertThat(tool.meta()).containsKey("ui/resourceUri"); + assertThat(tool.meta().get("ui/resourceUri")).isEqualTo("ui://test-server/app.html"); + + @SuppressWarnings("unchecked") + Map ui = (Map) tool.meta().get("ui"); + assertThat(ui.get("resourceUri")).isEqualTo("ui://test-server/app.html"); + assertThat(ui.get("visibility")).isEqualTo(List.of("app")); + } + }