Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<String, Object> structuredContent) {
return new McpAppResult(text, structuredContent);
}

}
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>
* 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.
*
* <p>
* Domain entries must be full origins including the scheme, for example
* {@code "https://api.example.com"}.
*
* <p>
* On the wire, these are serialized into {@code _meta.ui.csp} as:
*
* <pre>
* {
* "csp": {
* "connectDomains": ["https://api.example.com"],
* "resourceDomains": ["https://cdn.example.com"],
* "redirectDomains": ["https://auth.example.com"]
* }
* }
* </pre>
*
* @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 {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,34 @@
*/
Class<? extends MetaProvider> 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}.
*
* <p>
* 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}).
*
* <p>
* 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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* The visibility controls which consumers can see and invoke the tool:
* <ul>
* <li>{@link #MODEL} — visible to the LLM agent (default for all tools)</li>
* <li>{@link #APP} — visible to the MCP App iframe (backend tools called by the UI)</li>
* </ul>
*
* <p>
* 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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.springframework.ai.mcp.annotation.adapter;

import java.util.List;
import java.util.Locale;

import io.modelcontextprotocol.spec.McpSchema;

Expand All @@ -29,13 +30,49 @@
* {@link McpSchema.ResourceTemplate} instances from annotation metadata, including URI,
* name, description, MIME type, annotations, and optional {@code _meta} fields.
*
* <p>
* 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
* @author Craig Walls
*/
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() {
}

Expand All @@ -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())
Expand Down Expand Up @@ -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())
Expand Down
Loading
Loading