From 89a4488e3a4a0f89b884422a141fab527ee4448f Mon Sep 17 00:00:00 2001 From: Alexandros Pappas Date: Wed, 27 May 2026 11:34:07 +0200 Subject: [PATCH 1/2] feat(mcp-annotations): add SEP-1865 MCP Apps support - add resourceUri, visibility, csp fields to @McpTool - create Visibility enum and @McpCsp annotation - add MetaUtils.buildUiMeta() and mergeMeta() helpers - update all 4 tool providers to use merged meta - add McpAppResult return type with text/structuredContent split - handle McpAppResult in convertValueToCallToolResult - add ui:// URI + MIME type validation to ResourceAdapter - add comprehensive tests for all new functionality Signed-off-by: Alexandros Pappas --- .../ai/mcp/annotation/McpAppResult.java | 34 +++ .../ai/mcp/annotation/McpCsp.java | 69 ++++++ .../ai/mcp/annotation/McpTool.java | 28 +++ .../ai/mcp/annotation/Visibility.java | 63 ++++++ .../annotation/adapter/ResourceAdapter.java | 29 +++ .../ai/mcp/annotation/common/MetaUtils.java | 100 +++++++++ .../tool/AbstractMcpToolMethodCallback.java | 13 ++ .../provider/tool/AsyncMcpToolProvider.java | 5 +- .../tool/AsyncStatelessMcpToolProvider.java | 5 +- .../provider/tool/SyncMcpToolProvider.java | 5 +- .../tool/SyncStatelessMcpToolProvider.java | 5 +- .../ai/mcp/annotation/McpAppResultTests.java | 50 +++++ .../adapter/ResourceAdapterTests.java | 198 ++++++++++++++++++ .../mcp/annotation/common/MetaUtilsTest.java | 102 +++++++++ .../tool/SyncMcpToolProviderTests.java | 154 ++++++++++++++ 15 files changed, 856 insertions(+), 4 deletions(-) create mode 100644 mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpAppResult.java create mode 100644 mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpCsp.java create mode 100644 mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/Visibility.java create mode 100644 mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/McpAppResultTests.java create mode 100644 mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapterTests.java 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..c9ce7eb639 --- /dev/null +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpAppResult.java @@ -0,0 +1,34 @@ +/* + * 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[] + * @param structuredContent map sent to the widget UI as structuredContent + */ +public record McpAppResult(String text, Map structuredContent) { + + 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..a9e2f5fab1 --- /dev/null +++ b/mcp/mcp-annotations/src/main/java/org/springframework/ai/mcp/annotation/McpCsp.java @@ -0,0 +1,69 @@ +/* + * 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. + * + *

+ * 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: + *

    + *
  • {@link #MODEL} — visible to the LLM agent (default for all tools)
  • + *
  • {@link #APP} — visible to the MCP App iframe (backend tools called by the UI)
  • + *
+ * + *

+ * 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..3ee827e22c 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 @@ -29,6 +29,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 +41,8 @@ */ public final class ResourceAdapter { + private static final String MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; + private ResourceAdapter() { } @@ -46,6 +53,17 @@ public static McpSchema.Resource asResource(McpResource mcpResourceAnnotation) { } var meta = MetaUtils.getMeta(mcpResourceAnnotation.metaProvider()); + String uri = mcpResourceAnnotation.uri(); + String mimeType = mcpResourceAnnotation.mimeType(); + if (uri.startsWith("ui://") && !MCP_APP_MIME_TYPE.equals(mimeType)) { + throw new IllegalArgumentException( + "Resource with ui:// URI must use MIME type 'text/html;profile=mcp-app', but got: " + mimeType); + } + if (MCP_APP_MIME_TYPE.equals(mimeType) && !uri.startsWith("ui://")) { + throw new IllegalArgumentException( + "Resource with MIME type 'text/html;profile=mcp-app' must use a ui:// URI, but got: " + uri); + } + var resourceBuilder = McpSchema.Resource.builder(mcpResourceAnnotation.uri(), name) .title(mcpResourceAnnotation.title()) .description(mcpResourceAnnotation.description()) @@ -73,6 +91,17 @@ public static McpSchema.ResourceTemplate asResourceTemplate(McpResource mcpResou } var meta = MetaUtils.getMeta(mcpResource.metaProvider()); + String uri = mcpResource.uri(); + String mimeType = mcpResource.mimeType(); + if (uri.startsWith("ui://") && !MCP_APP_MIME_TYPE.equals(mimeType)) { + throw new IllegalArgumentException( + "Resource with ui:// URI must use MIME type 'text/html;profile=mcp-app', but got: " + mimeType); + } + if (MCP_APP_MIME_TYPE.equals(mimeType) && !uri.startsWith("ui://")) { + throw new IllegalArgumentException( + "Resource with MIME type 'text/html;profile=mcp-app' must use a ui:// URI, but got: " + uri); + } + 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..26f8b4803c 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,98 @@ 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.isEmpty()) { + 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); + } + } + + 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..7fdd771a98 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; @@ -186,6 +187,18 @@ protected CallToolResult convertValueToCallToolResult(Object result) { return CallToolResult.builder().addTextContent("null").build(); } + // For McpAppResult, split text into content[] and 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(); + } + // For string results in TEXT mode, return the string directly without JSON // serialization if (result instanceof String) { 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..103fe1ecec 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 @@ -83,7 +83,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) 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..ff0b60b89b 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 @@ -88,7 +88,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) 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..9931eda7bc 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 @@ -81,7 +81,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) 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..80cf04e224 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 @@ -85,7 +85,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) 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..4ea6595204 --- /dev/null +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/McpAppResultTests.java @@ -0,0 +1,50 @@ +/* + * 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; + +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 testNullValues() { + var result = McpAppResult.of(null, null); + assertThat(result.text()).isNull(); + assertThat(result.structuredContent()).isNull(); + } + +} 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..c36c16a3c2 --- /dev/null +++ b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/adapter/ResourceAdapterTests.java @@ -0,0 +1,198 @@ +/* + * 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"); + } + + // --------------------------------------------------------------------------- + // 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..e30b599755 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,103 @@ 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 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 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/SyncMcpToolProviderTests.java b/mcp/mcp-annotations/src/test/java/org/springframework/ai/mcp/annotation/provider/tool/SyncMcpToolProviderTests.java index e910f20aba..147fced0f5 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,9 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +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 +919,158 @@ 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 = "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("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"); + // Annotation-derived fields should be present + assertThat(ui.get("resourceUri")).isEqualTo("ui://test/view.html"); // provider + // wins + assertThat(ui).containsKey("visibility"); + // Provider's extra key should also be present (deep-merged) + assertThat(ui.get("visibility")).isInstanceOf(List.class); + // Provider wins on conflict — provider has ["model", "app"], annotation has + // ["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(); + } + public static class UiMetaProvider implements MetaProvider { @Override From 92eb063adb459d2b3f9a629af0bd9e91244aaffe Mon Sep 17 00:00:00 2001 From: Alexandros Pappas Date: Thu, 11 Jun 2026 18:19:42 +0200 Subject: [PATCH 2/2] fix(mcp-annotations): fix SEP-1865 McpAppResult and validation review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix McpAppResult handler dead-code path: move instanceof branch before STRUCTURED branch in AbstractMcpToolMethodCallback; null guard moved before both branches - exclude McpAppResult from output-schema generation in all 4 providers (Sync, SyncStateless, Async, AsyncStateless) — it is a wire-format container, not a structured data output - fix ui/resourceUri flat alias diverging from nested ui.resourceUri after MetaProvider override in MetaUtils.mergeMeta - extract duplicated ui:// vs MIME validation into validateMcpAppResource() helper in ResourceAdapter; add case-insensitive scheme matching (RFC 3986) and whitespace/case-lenient MIME comparison - add McpAppResult compact-constructor: both-null throws IllegalArgumentException - add @author Alexandros Pappas to McpAppResult.java - fix buildUiMeta blank-URI guard: isEmpty() -> isBlank() - document CSP domain format (full origins) in McpCsp javadoc; align test value from bare domain to https://api.example.com - fix contradictory comments in testToolWithResourceUriAndMetaProviderMerge - add generateOutputSchema=true E2E tests for McpAppResult across all 4 provider test classes; add UI-meta smoke tests to Async, AsyncStateless, SyncStateless providers Signed-off-by: Alexandros Pappas --- .../ai/mcp/annotation/McpAppResult.java | 13 ++- .../ai/mcp/annotation/McpCsp.java | 4 + .../annotation/adapter/ResourceAdapter.java | 52 +++++++----- .../ai/mcp/annotation/common/MetaUtils.java | 11 ++- .../tool/AbstractMcpToolMethodCallback.java | 19 +++-- .../provider/tool/AsyncMcpToolProvider.java | 6 +- .../tool/AsyncStatelessMcpToolProvider.java | 6 +- .../provider/tool/SyncMcpToolProvider.java | 8 +- .../tool/SyncStatelessMcpToolProvider.java | 8 +- .../ai/mcp/annotation/McpAppResultTests.java | 18 ++++- .../adapter/ResourceAdapterTests.java | 21 +++++ .../mcp/annotation/common/MetaUtilsTest.java | 20 +++++ .../tool/AsyncMcpToolProviderTests.java | 63 +++++++++++++++ .../AsyncStatelessMcpToolProviderTests.java | 63 +++++++++++++++ .../tool/SyncMcpToolProviderTests.java | 80 ++++++++++++++++--- .../SyncStatelessMcpToolProviderTests.java | 63 +++++++++++++++ 16 files changed, 403 insertions(+), 52 deletions(-) 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 index c9ce7eb639..34ff9543c4 100644 --- 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 @@ -22,11 +22,20 @@ * 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[] - * @param structuredContent map sent to the widget UI as structuredContent + * @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 index a9e2f5fab1..8bb4118cff 100644 --- 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 @@ -31,6 +31,10 @@ * 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: * *

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 3ee827e22c..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;
 
@@ -43,6 +44,35 @@ 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() {
 	}
 
@@ -53,16 +83,7 @@ public static McpSchema.Resource asResource(McpResource mcpResourceAnnotation) {
 		}
 		var meta = MetaUtils.getMeta(mcpResourceAnnotation.metaProvider());
 
-		String uri = mcpResourceAnnotation.uri();
-		String mimeType = mcpResourceAnnotation.mimeType();
-		if (uri.startsWith("ui://") && !MCP_APP_MIME_TYPE.equals(mimeType)) {
-			throw new IllegalArgumentException(
-					"Resource with ui:// URI must use MIME type 'text/html;profile=mcp-app', but got: " + mimeType);
-		}
-		if (MCP_APP_MIME_TYPE.equals(mimeType) && !uri.startsWith("ui://")) {
-			throw new IllegalArgumentException(
-					"Resource with MIME type 'text/html;profile=mcp-app' must use a ui:// URI, but got: " + uri);
-		}
+		validateMcpAppResource(mcpResourceAnnotation.uri(), mcpResourceAnnotation.mimeType());
 
 		var resourceBuilder = McpSchema.Resource.builder(mcpResourceAnnotation.uri(), name)
 			.title(mcpResourceAnnotation.title())
@@ -91,16 +112,7 @@ public static McpSchema.ResourceTemplate asResourceTemplate(McpResource mcpResou
 		}
 		var meta = MetaUtils.getMeta(mcpResource.metaProvider());
 
-		String uri = mcpResource.uri();
-		String mimeType = mcpResource.mimeType();
-		if (uri.startsWith("ui://") && !MCP_APP_MIME_TYPE.equals(mimeType)) {
-			throw new IllegalArgumentException(
-					"Resource with ui:// URI must use MIME type 'text/html;profile=mcp-app', but got: " + mimeType);
-		}
-		if (MCP_APP_MIME_TYPE.equals(mimeType) && !uri.startsWith("ui://")) {
-			throw new IllegalArgumentException(
-					"Resource with MIME type 'text/html;profile=mcp-app' must use a ui:// URI, but got: " + uri);
-		}
+		validateMcpAppResource(mcpResource.uri(), mcpResource.mimeType());
 
 		return McpSchema.ResourceTemplate.builder(mcpResource.uri(), name)
 			.description(mcpResource.description())
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 26f8b4803c..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
@@ -124,7 +124,7 @@ private static Constructor getConstructor(Class buildUiMeta(String resourceUri, Visibility[] visibility, McpCsp csp) {
-		if (resourceUri == null || resourceUri.isEmpty()) {
+		if (resourceUri == null || resourceUri.isBlank()) {
 			return null;
 		}
 
@@ -200,6 +200,15 @@ public static Map mergeMeta(Map providerMeta, Ma
 			}
 		}
 
+		// 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 7fdd771a98..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
@@ -176,18 +176,13 @@ protected CallToolResult convertValueToCallToolResult(Object result) {
 			return CallToolResult.builder().addTextContent(jsonHelper.toJson("Done")).build();
 		}
 
-		if (this.returnMode == ReturnMode.STRUCTURED) {
-			String jsonOutput = jsonHelper.toJson(result);
-			Object structuredOutput = jsonHelper.fromJson(jsonOutput, Object.class);
-			return CallToolResult.builder().structuredContent(structuredOutput).build();
-		}
-
-		// Default to text output
 		if (result == null) {
 			return CallToolResult.builder().addTextContent("null").build();
 		}
 
-		// For McpAppResult, split text into content[] and structuredContent
+		// 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) {
@@ -199,6 +194,14 @@ protected CallToolResult convertValueToCallToolResult(Object result) {
 			return builder.build();
 		}
 
+		if (this.returnMode == ReturnMode.STRUCTURED) {
+			String jsonOutput = jsonHelper.toJson(result);
+			Object structuredOutput = jsonHelper.fromJson(jsonOutput, Object.class);
+			return CallToolResult.builder().structuredContent(structuredOutput).build();
+		}
+
+		// Default to text output
+
 		// For string results in TEXT mode, return the string directly without JSON
 		// serialization
 		if (result instanceof String) {
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 103fe1ecec..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;
@@ -121,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)
@@ -130,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 ff0b60b89b..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;
@@ -126,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)
@@ -135,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 9931eda7bc..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;
@@ -119,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 80cf04e224..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;
@@ -123,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
index 4ea6595204..7b1543e7b4 100644
--- 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
@@ -21,6 +21,7 @@
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 
 class McpAppResultTests {
 
@@ -41,10 +42,23 @@ void testStaticFactory() {
 	}
 
 	@Test
-	void testNullValues() {
-		var result = McpAppResult.of(null, null);
+	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
index c36c16a3c2..8cdf6f04c8 100644
--- 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
@@ -60,6 +60,27 @@ void asResource_regularUriWithNullMimeType_succeeds() {
 		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
 	// ---------------------------------------------------------------------------
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 e30b599755..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
@@ -141,6 +141,12 @@ void testBuildUiMetaWithEmptyResourceUri() {
 		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",
@@ -160,6 +166,20 @@ void testMergeMetaProviderWinsOnConflict() {
 		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);
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 147fced0f5..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,6 +34,7 @@
 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;
@@ -985,7 +986,8 @@ void testToolWithResourceUriAndCsp() {
 		class ResourceUriCspTool {
 
 			@McpTool(name = "csp-tool", description = "Tool with resourceUri and CSP",
-					resourceUri = "ui://test-server/app.html", csp = @McpCsp(connectDomains = "api.example.com"))
+					resourceUri = "ui://test-server/app.html",
+					csp = @McpCsp(connectDomains = "https://api.example.com"))
 			public String cspTool(String input) {
 				return "result: " + input;
 			}
@@ -1011,7 +1013,7 @@ public String cspTool(String input) {
 		assertThat(csp.get("connectDomains")).isInstanceOf(List.class);
 		@SuppressWarnings("unchecked")
 		List connectDomains = (List) csp.get("connectDomains");
-		assertThat(connectDomains).containsExactly("api.example.com");
+		assertThat(connectDomains).containsExactly("https://api.example.com");
 	}
 
 	@Test
@@ -1038,13 +1040,10 @@ public String mergeTool(String input) {
 
 		@SuppressWarnings("unchecked")
 		Map ui = (Map) tool.meta().get("ui");
-		// Annotation-derived fields should be present
-		assertThat(ui.get("resourceUri")).isEqualTo("ui://test/view.html"); // provider
-																			// wins
-		assertThat(ui).containsKey("visibility");
-		// Provider's extra key should also be present (deep-merged)
-		assertThat(ui.get("visibility")).isInstanceOf(List.class);
-		// Provider wins on conflict — provider has ["model", "app"], annotation has
+		// 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");
@@ -1071,6 +1070,69 @@ public String plainTool(String input) {
 		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"));
+	}
+
 }