From bb32d0ee20ca998082f67d8e566f161348498714 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 19:46:21 +0000
Subject: [PATCH 1/2] Initial plan
From 35e7e59607a51774e90eb701c831d945f72c69d6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 31 Mar 2026 20:36:14 +0000
Subject: [PATCH 2/2] feat: Add MCP Apps extension support (F1-F3, F6, F7)
Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/5ec8e2cd-39e5-4b4c-a18e-182ccaaa7637
Co-authored-by: mikekistler <85643503+mikekistler@users.noreply.github.com>
---
docs/list-of-diagnostics.md | 2 +-
src/Common/Experimentals.cs | 19 +
.../McpJsonUtilities.cs | 7 +
.../Server/AIFunctionMcpServerTool.cs | 39 +-
.../Server/McpAppUiAttribute.cs | 56 +++
.../Server/McpApps.cs | 113 +++++
.../Server/McpServerToolCreateOptions.cs | 31 ++
.../Server/McpUiClientCapabilities.cs | 30 ++
.../Server/McpUiResourceCsp.cs | 49 +++
.../Server/McpUiResourceMeta.cs | 50 +++
.../Server/McpUiResourcePermissions.cs | 27 ++
.../Server/McpUiToolMeta.cs | 45 ++
.../Server/McpUiToolVisibility.cs | 26 ++
.../Server/McpAppsTests.cs | 403 ++++++++++++++++++
14 files changed, 893 insertions(+), 4 deletions(-)
create mode 100644 src/ModelContextProtocol.Core/Server/McpAppUiAttribute.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpApps.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpUiClientCapabilities.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpUiResourceCsp.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpUiResourceMeta.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpUiResourcePermissions.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpUiToolMeta.cs
create mode 100644 src/ModelContextProtocol.Core/Server/McpUiToolVisibility.cs
create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md
index 515472817..ebbad5907 100644
--- a/docs/list-of-diagnostics.md
+++ b/docs/list-of-diagnostics.md
@@ -23,7 +23,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T
| Diagnostic ID | Description |
| :------------ | :---------- |
-| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). |
+| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks, Extensions, and the MCP Apps extension. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). MCP Apps is the first official MCP extension, enabling servers to deliver interactive UIs inside AI clients (see [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). |
| `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). |
## Obsolete APIs
diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs
index 7e7e969bb..54904db2a 100644
--- a/src/Common/Experimentals.cs
+++ b/src/Common/Experimentals.cs
@@ -71,6 +71,25 @@ internal static class Experimentals
///
public const string Extensions_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
+ ///
+ /// Diagnostic ID for experimental MCP Apps extension APIs.
+ ///
+ ///
+ /// This uses the same diagnostic ID as because
+ /// MCP Apps is implemented as an MCP extension ("io.modelcontextprotocol/ui" ).
+ ///
+ public const string Apps_DiagnosticId = "MCPEXP001";
+
+ ///
+ /// Message for the experimental MCP Apps extension APIs.
+ ///
+ public const string Apps_Message = "The MCP Apps extension is experimental and subject to change as the specification evolves.";
+
+ ///
+ /// URL for the experimental MCP Apps extension APIs.
+ ///
+ public const string Apps_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
+
///
/// Diagnostic ID for experimental SDK APIs unrelated to the MCP specification,
/// such as subclassing McpClient /McpServer or referencing RunSessionHandler .
diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index abb6d29df..13a935311 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -187,6 +187,13 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(DynamicClientRegistrationRequest))]
[JsonSerializable(typeof(DynamicClientRegistrationResponse))]
+ // MCP Apps extension types
+ [JsonSerializable(typeof(Server.McpUiToolMeta))]
+ [JsonSerializable(typeof(Server.McpUiClientCapabilities))]
+ [JsonSerializable(typeof(Server.McpUiResourceMeta))]
+ [JsonSerializable(typeof(Server.McpUiResourceCsp))]
+ [JsonSerializable(typeof(Server.McpUiResourcePermissions))]
+
// Primitive types for use in consuming AIFunctions
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(byte))]
diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
index 700d9d26d..a83dd33ae 100644
--- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
+++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
@@ -144,10 +144,21 @@ options.OpenWorld is not null ||
};
}
- // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available
+ // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available.
+ // Priority order (highest to lowest):
+ // 1. Explicit options.Meta entries
+ // 2. AppUi metadata (from McpAppUiAttribute or McpServerToolCreateOptions.AppUi)
+ // 3. McpMetaAttribute entries on the method
+ JsonObject? seededMeta = options.Meta;
+ if (options.AppUi is { } appUi)
+ {
+ seededMeta = seededMeta is not null ? CloneJsonObject(seededMeta) : new JsonObject();
+ McpApps.ApplyUiToolMetaToJsonObject(appUi, seededMeta);
+ }
+
tool.Meta = function.UnderlyingMethod is not null ?
- CreateMetaFromAttributes(function.UnderlyingMethod, options.Meta) :
- options.Meta;
+ CreateMetaFromAttributes(function.UnderlyingMethod, seededMeta) :
+ seededMeta;
// Apply user-specified Execution settings if provided
if (options.Execution is not null)
@@ -225,6 +236,16 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
newOptions.Description ??= descAttr.Description;
}
+ // Process McpAppUiAttribute — takes precedence over options.AppUi set via constructor.
+ if (method.GetCustomAttribute() is { } appUiAttr)
+ {
+ newOptions.AppUi = new McpUiToolMeta
+ {
+ ResourceUri = appUiAttr.ResourceUri,
+ Visibility = appUiAttr.Visibility,
+ };
+ }
+
// Set metadata if not already provided
newOptions.Metadata ??= CreateMetadata(method);
@@ -405,6 +426,18 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method)
return meta;
}
+ /// Creates a shallow-content clone of a so that keys can be added without mutating the original.
+ private static JsonObject CloneJsonObject(JsonObject source)
+ {
+ var clone = new JsonObject();
+ foreach (var kvp in source)
+ {
+ // DeepClone each value to avoid sharing nodes between two JsonObject instances.
+ clone[kvp.Key] = kvp.Value?.DeepClone();
+ }
+ return clone;
+ }
+
#if NET
/// Regex that flags runs of characters other than ASCII digits or letters.
[GeneratedRegex("[^0-9A-Za-z]+")]
diff --git a/src/ModelContextProtocol.Core/Server/McpAppUiAttribute.cs b/src/ModelContextProtocol.Core/Server/McpAppUiAttribute.cs
new file mode 100644
index 000000000..6d056b9bf
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpAppUiAttribute.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Specifies MCP Apps UI metadata for a tool method.
+///
+///
+///
+/// Apply this attribute alongside to associate a tool with a
+/// UI resource in the MCP Apps extension. When processed, it populates both the structured
+/// _meta.ui object and the legacy _meta["ui/resourceUri"] flat key in the tool's
+/// metadata for backward compatibility with older MCP hosts.
+///
+///
+/// This attribute takes precedence over any raw [McpMeta("ui", ...)] attribute on the
+/// same method.
+///
+///
+///
+///
+/// [McpServerTool]
+/// [McpAppUi(ResourceUri = "ui://weather/view.html")]
+/// [Description("Get current weather for a location")]
+/// public string GetWeather(string location) => ...;
+///
+/// // Restrict visibility to model only:
+/// [McpServerTool]
+/// [McpAppUi(ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.Model])]
+/// public string GetWeatherModelOnly(string location) => ...;
+///
+///
+[AttributeUsage(AttributeTargets.Method)]
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public sealed class McpAppUiAttribute : Attribute
+{
+ ///
+ /// Gets or sets the URI of the UI resource associated with this tool.
+ ///
+ ///
+ /// This should be a ui:// URI pointing to the HTML resource registered
+ /// with the server (e.g., "ui://weather/view.html" ).
+ ///
+ public string? ResourceUri { get; set; }
+
+ ///
+ /// Gets or sets the visibility of the tool, controlling which principals can invoke it.
+ ///
+ ///
+ ///
+ /// Allowed values are and .
+ /// When or empty, the tool is visible to both the model and the app (the default).
+ ///
+ ///
+ public string[]? Visibility { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpApps.cs b/src/ModelContextProtocol.Core/Server/McpApps.cs
new file mode 100644
index 000000000..4f5292849
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpApps.cs
@@ -0,0 +1,113 @@
+using ModelContextProtocol.Protocol;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Provides constants and helper methods for building MCP Apps-enabled servers.
+///
+///
+///
+/// MCP Apps is an extension to the Model Context Protocol that enables MCP servers to deliver
+/// interactive user interfaces — dashboards, forms, visualizations, and more — directly inside
+/// conversational AI clients.
+///
+///
+/// Use the constants in this class when populating the extensions capability and the
+/// _meta field of tools and resources. Use to check whether
+/// the connected client supports the MCP Apps extension.
+///
+///
+public static class McpApps
+{
+ ///
+ /// The MIME type used for MCP App HTML resources.
+ ///
+ ///
+ /// This MIME type should be used when registering UI resources with
+ /// text/html;profile=mcp-app to indicate they are MCP App resources.
+ ///
+ public const string ResourceMimeType = "text/html;profile=mcp-app";
+
+ ///
+ /// The extension identifier used for MCP Apps capability negotiation.
+ ///
+ ///
+ /// This key is used in the and
+ /// dictionaries to advertise support for
+ /// the MCP Apps extension.
+ ///
+ public const string ExtensionId = "io.modelcontextprotocol/ui";
+
+ ///
+ /// The legacy flat _meta key for the UI resource URI.
+ ///
+ ///
+ ///
+ /// This key is used for backward compatibility with older MCP hosts that do not support
+ /// the nested _meta.ui object. When populating UI metadata, both this key and the
+ /// ui object should be set to the same resource URI value.
+ ///
+ ///
+ /// This key is considered legacy; prefer for new implementations.
+ ///
+ ///
+ public const string ResourceUriMetaKey = "ui/resourceUri";
+
+ ///
+ /// Gets the MCP Apps client capability, if advertised by the connected client.
+ ///
+ /// The client capabilities received during the MCP initialize handshake.
+ ///
+ /// A instance if the client advertises support for the MCP Apps extension;
+ /// otherwise, .
+ ///
+ ///
+ /// Use this method to determine whether the connected client supports the MCP Apps extension
+ /// and to read the client's supported MIME types.
+ ///
+ [Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+ public static McpUiClientCapabilities? GetUiCapability(ClientCapabilities? capabilities)
+ {
+ if (capabilities?.Extensions is not { } extensions ||
+ !extensions.TryGetValue(ExtensionId, out var value))
+ {
+ return null;
+ }
+
+ if (value is JsonElement element)
+ {
+ return element.ValueKind == JsonValueKind.Null ? null :
+ JsonSerializer.Deserialize(element, McpJsonUtilities.JsonContext.Default.McpUiClientCapabilities);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Applies UI tool metadata to a , setting both the
+ /// ui object key and the legacy ui/resourceUri flat key for backward compatibility.
+ /// Keys already present in are not overwritten.
+ ///
+ /// The UI tool metadata to apply.
+ /// The to populate.
+ internal static void ApplyUiToolMetaToJsonObject(McpUiToolMeta appUi, System.Text.Json.Nodes.JsonObject meta)
+ {
+ // Populate the structured "ui" object if not already present.
+ if (!meta.ContainsKey("ui"))
+ {
+ var uiNode = JsonSerializer.SerializeToNode(appUi, McpJsonUtilities.JsonContext.Default.McpUiToolMeta);
+ if (uiNode is not null)
+ {
+ meta["ui"] = uiNode;
+ }
+ }
+
+ // Populate the legacy flat "ui/resourceUri" key if not already present.
+ if (!meta.ContainsKey(ResourceUriMetaKey) && appUi.ResourceUri is not null)
+ {
+ meta[ResourceUriMetaKey] = appUi.ResourceUri;
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
index 88d718d13..33c5b1c82 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
@@ -197,6 +197,36 @@ public sealed class McpServerToolCreateOptions
///
public JsonObject? Meta { get; set; }
+ ///
+ /// Gets or sets the MCP Apps UI metadata for this tool.
+ ///
+ ///
+ ///
+ /// When set, this metadata is merged into during tool creation, populating
+ /// both the structured _meta.ui object and the legacy _meta["ui/resourceUri"]
+ /// flat key for backward compatibility with older MCP hosts.
+ ///
+ ///
+ /// Explicit entries already present in take precedence over values from
+ /// this property. The on a method overrides this property
+ /// when both are specified.
+ ///
+ ///
+ ///
+ ///
+ /// var tool = McpServerTool.Create(handler, new McpServerToolCreateOptions
+ /// {
+ /// AppUi = new McpUiToolMeta
+ /// {
+ /// ResourceUri = "ui://weather/view.html",
+ /// Visibility = [McpUiToolVisibility.Model, McpUiToolVisibility.App]
+ /// }
+ /// });
+ ///
+ ///
+ [Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+ public McpUiToolMeta? AppUi { get; set; }
+
///
/// Gets or sets the execution hints for this tool.
///
@@ -235,6 +265,7 @@ internal McpServerToolCreateOptions Clone() =>
Metadata = Metadata,
Icons = Icons,
Meta = Meta,
+ AppUi = AppUi,
Execution = Execution,
};
}
diff --git a/src/ModelContextProtocol.Core/Server/McpUiClientCapabilities.cs b/src/ModelContextProtocol.Core/Server/McpUiClientCapabilities.cs
new file mode 100644
index 000000000..91e649d59
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpUiClientCapabilities.cs
@@ -0,0 +1,30 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents the MCP Apps capabilities advertised by a client.
+///
+///
+///
+/// This object is the value associated with the key in the
+/// dictionary.
+///
+///
+/// Use to read this from .
+///
+///
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public sealed class McpUiClientCapabilities
+{
+ ///
+ /// Gets or sets the list of MIME types supported by the client for MCP App UI resources.
+ ///
+ ///
+ /// A client that supports MCP Apps must include
+ /// ("text/html;profile=mcp-app" ) in this list.
+ ///
+ [JsonPropertyName("mimeTypes")]
+ public IList? MimeTypes { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpUiResourceCsp.cs b/src/ModelContextProtocol.Core/Server/McpUiResourceCsp.cs
new file mode 100644
index 000000000..60064863f
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpUiResourceCsp.cs
@@ -0,0 +1,49 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents the Content Security Policy (CSP) domain allowlists for an MCP Apps UI resource.
+///
+///
+///
+/// These allowlists are used by the MCP host to construct the Content-Security-Policy HTTP header
+/// for the sandboxed iframe that hosts the UI resource.
+///
+///
+/// Each list contains origins (e.g., "https://api.example.com" ) that are permitted for
+/// the corresponding CSP directive.
+///
+///
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public sealed class McpUiResourceCsp
+{
+ ///
+ /// Gets or sets the list of origins allowed for fetch, XMLHttpRequest, WebSocket, and EventSource
+ /// connections (connect-src CSP directive).
+ ///
+ [JsonPropertyName("connectDomains")]
+ public IList? ConnectDomains { get; set; }
+
+ ///
+ /// Gets or sets the list of origins allowed for loading scripts, stylesheets, images, and fonts
+ /// (script-src , style-src , img-src , font-src CSP directives).
+ ///
+ [JsonPropertyName("resourceDomains")]
+ public IList? ResourceDomains { get; set; }
+
+ ///
+ /// Gets or sets the list of origins allowed for loading nested frames
+ /// (frame-src CSP directive).
+ ///
+ [JsonPropertyName("frameDomains")]
+ public IList? FrameDomains { get; set; }
+
+ ///
+ /// Gets or sets the list of allowed base URIs
+ /// (base-uri CSP directive).
+ ///
+ [JsonPropertyName("baseUris")]
+ public IList? BaseUris { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpUiResourceMeta.cs b/src/ModelContextProtocol.Core/Server/McpUiResourceMeta.cs
new file mode 100644
index 000000000..c8f3b3ed6
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpUiResourceMeta.cs
@@ -0,0 +1,50 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents the UI metadata associated with an MCP resource in the MCP Apps extension.
+///
+///
+/// This metadata is placed under the ui key in the resource's _meta object.
+/// It provides Content Security Policy (CSP) configuration, sandbox permissions, CORS origin, and
+/// visual boundary preferences for the UI resource served by this MCP server.
+///
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public sealed class McpUiResourceMeta
+{
+ ///
+ /// Gets or sets the Content Security Policy configuration for this resource.
+ ///
+ ///
+ /// Specifies the allowed origins for network requests, resource loads, and nested frames.
+ ///
+ [JsonPropertyName("csp")]
+ public McpUiResourceCsp? Csp { get; set; }
+
+ ///
+ /// Gets or sets the sandbox permissions for this resource.
+ ///
+ ///
+ /// Controls which browser sandbox features the UI resource is allowed to use.
+ ///
+ [JsonPropertyName("permissions")]
+ public McpUiResourcePermissions? Permissions { get; set; }
+
+ ///
+ /// Gets or sets the dedicated origin domain for this resource.
+ ///
+ ///
+ /// When set, the host will serve the resource from this dedicated origin,
+ /// enabling OAuth flows and CORS without wildcard exceptions.
+ ///
+ [JsonPropertyName("domain")]
+ public string? Domain { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the host should render a visual border around the UI.
+ ///
+ [JsonPropertyName("prefersBorder")]
+ public bool? PrefersBorder { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpUiResourcePermissions.cs b/src/ModelContextProtocol.Core/Server/McpUiResourcePermissions.cs
new file mode 100644
index 000000000..f8ff58041
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpUiResourcePermissions.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents the sandbox permissions requested by an MCP Apps UI resource.
+///
+///
+/// This maps to the allow attribute on the iframe sandbox in the MCP host.
+/// Permissions are specified as standard browser iframe permission strings,
+/// such as "camera" , "microphone" , or "geolocation" .
+///
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public sealed class McpUiResourcePermissions
+{
+ ///
+ /// Gets or sets the list of permissions granted to the sandboxed UI resource.
+ ///
+ ///
+ /// These correspond to values allowed in the allow attribute of an HTML iframe,
+ /// for example "camera" , "microphone" , "geolocation" ,
+ /// "clipboard-read" , or "clipboard-write" .
+ ///
+ [JsonPropertyName("allow")]
+ public IList? Allow { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpUiToolMeta.cs b/src/ModelContextProtocol.Core/Server/McpUiToolMeta.cs
new file mode 100644
index 000000000..f56396e29
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpUiToolMeta.cs
@@ -0,0 +1,45 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents the UI metadata associated with an MCP tool in the MCP Apps extension.
+///
+///
+///
+/// This metadata is placed under the ui key in the tool's _meta object.
+/// It associates the tool with a UI resource (identified by a ui:// URI) and optionally
+/// controls which principals (model, app) can call the tool.
+///
+///
+/// When this metadata is applied, both the structured _meta.ui object and the legacy
+/// _meta["ui/resourceUri"] flat key are populated for backward compatibility with older hosts.
+///
+///
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public sealed class McpUiToolMeta
+{
+ ///
+ /// Gets or sets the URI of the UI resource associated with this tool.
+ ///
+ ///
+ /// This should be a ui:// URI pointing to the HTML resource registered
+ /// with the server (e.g., "ui://weather/view.html" ).
+ ///
+ [JsonPropertyName("resourceUri")]
+ public string? ResourceUri { get; set; }
+
+ ///
+ /// Gets or sets the visibility of the tool, controlling which principals can invoke it.
+ ///
+ ///
+ ///
+ /// Allowed values are ("model" ) and
+ /// ("app" ). When
+ /// or empty, the tool is visible to both the model and the app (the default).
+ ///
+ ///
+ [JsonPropertyName("visibility")]
+ public IList? Visibility { get; set; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpUiToolVisibility.cs b/src/ModelContextProtocol.Core/Server/McpUiToolVisibility.cs
new file mode 100644
index 000000000..e9a454c25
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpUiToolVisibility.cs
@@ -0,0 +1,26 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Provides well-known visibility values for .
+///
+///
+/// Use these constants to specify which principals can invoke a tool in the MCP Apps extension.
+/// When is or empty, the tool
+/// is visible to both the model and the app by default.
+///
+[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
+public static class McpUiToolVisibility
+{
+ ///
+ /// Indicates that the tool can be invoked by the AI model.
+ ///
+ public const string Model = "model";
+
+ ///
+ /// Indicates that the tool can be invoked by the UI app (iframe).
+ ///
+ public const string App = "app";
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
new file mode 100644
index 000000000..f0d76def5
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
@@ -0,0 +1,403 @@
+#pragma warning disable MCPEXP001
+
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// Tests for MCP Apps extension support: McpApps constants, typed metadata models,
+/// McpAppUiAttribute, and McpServerToolCreateOptions.AppUi.
+///
+public class McpAppsTests
+{
+ #region F1: Constants
+
+ [Fact]
+ public void McpApps_Constants_HaveExpectedValues()
+ {
+ Assert.Equal("text/html;profile=mcp-app", McpApps.ResourceMimeType);
+ Assert.Equal("io.modelcontextprotocol/ui", McpApps.ExtensionId);
+ Assert.Equal("ui/resourceUri", McpApps.ResourceUriMetaKey);
+ }
+
+ [Fact]
+ public void McpUiToolVisibility_Constants_HaveExpectedValues()
+ {
+ Assert.Equal("model", McpUiToolVisibility.Model);
+ Assert.Equal("app", McpUiToolVisibility.App);
+ }
+
+ #endregion
+
+ #region F2: Typed Metadata Models
+
+ [Fact]
+ public void McpUiToolMeta_DefaultsToNull()
+ {
+ var meta = new McpUiToolMeta();
+ Assert.Null(meta.ResourceUri);
+ Assert.Null(meta.Visibility);
+ }
+
+ [Fact]
+ public void McpUiToolMeta_CanBeRoundtrippedAsJson()
+ {
+ var meta = new McpUiToolMeta
+ {
+ ResourceUri = "ui://weather/view.html",
+ Visibility = [McpUiToolVisibility.Model, McpUiToolVisibility.App],
+ };
+
+ var json = JsonSerializer.Serialize(meta, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("ui://weather/view.html", deserialized.ResourceUri);
+ Assert.Equal(["model", "app"], deserialized.Visibility);
+ }
+
+ [Fact]
+ public void McpUiToolMeta_OmitsNullProperties()
+ {
+ var meta = new McpUiToolMeta { ResourceUri = "ui://app" };
+ var json = JsonSerializer.Serialize(meta, McpJsonUtilities.DefaultOptions);
+ var doc = JsonDocument.Parse(json);
+
+ Assert.True(doc.RootElement.TryGetProperty("resourceUri", out _));
+ Assert.False(doc.RootElement.TryGetProperty("visibility", out _));
+ }
+
+ [Fact]
+ public void McpUiResourceMeta_CanBeRoundtrippedAsJson()
+ {
+ var meta = new McpUiResourceMeta
+ {
+ Domain = "https://app.example.com",
+ PrefersBorder = true,
+ Csp = new McpUiResourceCsp
+ {
+ ConnectDomains = ["https://api.example.com"],
+ ResourceDomains = ["https://cdn.example.com"],
+ FrameDomains = ["https://embed.example.com"],
+ BaseUris = ["https://app.example.com"],
+ },
+ Permissions = new McpUiResourcePermissions
+ {
+ Allow = ["camera", "microphone"],
+ },
+ };
+
+ var json = JsonSerializer.Serialize(meta, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("https://app.example.com", deserialized.Domain);
+ Assert.True(deserialized.PrefersBorder);
+ Assert.NotNull(deserialized.Csp);
+ Assert.Equal(["https://api.example.com"], deserialized.Csp.ConnectDomains);
+ Assert.Equal(["https://cdn.example.com"], deserialized.Csp.ResourceDomains);
+ Assert.Equal(["https://embed.example.com"], deserialized.Csp.FrameDomains);
+ Assert.Equal(["https://app.example.com"], deserialized.Csp.BaseUris);
+ Assert.NotNull(deserialized.Permissions);
+ Assert.Equal(["camera", "microphone"], deserialized.Permissions.Allow);
+ }
+
+ [Fact]
+ public void McpUiClientCapabilities_CanBeRoundtrippedAsJson()
+ {
+ var caps = new McpUiClientCapabilities
+ {
+ MimeTypes = [McpApps.ResourceMimeType],
+ };
+
+ var json = JsonSerializer.Serialize(caps, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal([McpApps.ResourceMimeType], deserialized.MimeTypes);
+ }
+
+ #endregion
+
+ #region F3: GetUiCapability
+
+ [Fact]
+ public void GetUiCapability_ReturnsNull_WhenCapabilitiesIsNull()
+ {
+ Assert.Null(McpApps.GetUiCapability(null));
+ }
+
+ [Fact]
+ public void GetUiCapability_ReturnsNull_WhenExtensionsIsNull()
+ {
+ var caps = new ClientCapabilities();
+ Assert.Null(McpApps.GetUiCapability(caps));
+ }
+
+ [Fact]
+ public void GetUiCapability_ReturnsNull_WhenExtensionKeyIsMissing()
+ {
+#pragma warning disable MCPEXP001
+ var caps = new ClientCapabilities
+ {
+ Extensions = new Dictionary
+ {
+ ["other.extension"] = new { },
+ }
+ };
+#pragma warning restore MCPEXP001
+ Assert.Null(McpApps.GetUiCapability(caps));
+ }
+
+ [Fact]
+ public void GetUiCapability_ReturnsCapabilities_WhenExtensionIsPresent()
+ {
+ // Simulate what the SDK does when deserializing ClientCapabilities from JSON:
+ // extensions values come in as JsonElement.
+ var json = $$"""
+ {
+ "extensions": {
+ "{{McpApps.ExtensionId}}": {
+ "mimeTypes": ["{{McpApps.ResourceMimeType}}"]
+ }
+ }
+ }
+ """;
+
+ var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ Assert.NotNull(caps);
+
+ var uiCaps = McpApps.GetUiCapability(caps);
+
+ Assert.NotNull(uiCaps);
+ Assert.Equal([McpApps.ResourceMimeType], uiCaps.MimeTypes);
+ }
+
+ [Fact]
+ public void GetUiCapability_ReturnsNull_WhenExtensionValueIsNull()
+ {
+ var json = $$"""
+ {
+ "extensions": {
+ "{{McpApps.ExtensionId}}": null
+ }
+ }
+ """;
+
+ var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ Assert.NotNull(caps);
+
+ Assert.Null(McpApps.GetUiCapability(caps));
+ }
+
+ #endregion
+
+ #region F6: McpAppUiAttribute
+
+ [Fact]
+ public void McpAppUiAttribute_PopulatesBothUiObjectAndLegacyKey()
+ {
+ var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!;
+ var tool = McpServerTool.Create(method, target: null);
+
+ var meta = tool.ProtocolTool.Meta;
+ Assert.NotNull(meta);
+
+ // Structured "ui" object
+ var uiNode = meta["ui"]?.AsObject();
+ Assert.NotNull(uiNode);
+ Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue());
+
+ // Legacy flat key
+ Assert.Equal("ui://weather/view.html", meta[McpApps.ResourceUriMetaKey]?.GetValue());
+ }
+
+ [Fact]
+ public void McpAppUiAttribute_WithVisibility_IncludesVisibilityInUiObject()
+ {
+ var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!;
+ var tool = McpServerTool.Create(method, target: null);
+
+ var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject();
+ Assert.NotNull(uiNode);
+ Assert.Equal("ui://model-only/view.html", uiNode["resourceUri"]?.GetValue());
+
+ var visibility = uiNode["visibility"]?.AsArray();
+ Assert.NotNull(visibility);
+ Assert.Single(visibility);
+ Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue());
+ }
+
+ [Fact]
+ public void McpAppUiAttribute_TakesPrecedenceOver_McpMetaAttribute()
+ {
+ // The tool has both [McpAppUi] and [McpMeta("ui", ...)] — AppUi should win for the "ui" key.
+ var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ToolWithBothAttributes))!;
+ var tool = McpServerTool.Create(method, target: null);
+
+ var meta = tool.ProtocolTool.Meta;
+ Assert.NotNull(meta);
+
+ // The "ui" key should be from McpAppUiAttribute, not McpMetaAttribute
+ var uiNode = meta["ui"]?.AsObject();
+ Assert.NotNull(uiNode);
+ Assert.Equal("ui://app-ui/view.html", uiNode["resourceUri"]?.GetValue());
+
+ // The legacy key should be from McpAppUiAttribute
+ Assert.Equal("ui://app-ui/view.html", meta[McpApps.ResourceUriMetaKey]?.GetValue());
+
+ // Other McpMeta attributes should still be present
+ Assert.Equal("extra-value", meta["extraKey"]?.GetValue());
+ }
+
+ [Fact]
+ public void McpAppUiAttribute_ExplicitOptionsMeta_TakesPrecedenceOver_Attribute()
+ {
+ // Explicit Meta["ui"] in options should override the attribute
+ var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!;
+ var explicitMeta = new JsonObject
+ {
+ ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/override.html" },
+ [McpApps.ResourceUriMetaKey] = "ui://explicit/override.html",
+ };
+
+ var tool = McpServerTool.Create(method, target: null, new McpServerToolCreateOptions { Meta = explicitMeta });
+
+ var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject();
+ Assert.Equal("ui://explicit/override.html", uiNode?["resourceUri"]?.GetValue());
+ Assert.Equal("ui://explicit/override.html", tool.ProtocolTool.Meta?[McpApps.ResourceUriMetaKey]?.GetValue());
+ }
+
+ #endregion
+
+ #region F7: McpServerToolCreateOptions.AppUi
+
+ [Fact]
+ public void AppUi_PopulatesBothUiObjectAndLegacyKey()
+ {
+ var tool = McpServerTool.Create(
+ (string location) => $"Weather for {location}",
+ new McpServerToolCreateOptions
+ {
+ Name = "get_weather",
+ AppUi = new McpUiToolMeta { ResourceUri = "ui://weather/view.html" },
+ });
+
+ var meta = tool.ProtocolTool.Meta;
+ Assert.NotNull(meta);
+
+ var uiNode = meta["ui"]?.AsObject();
+ Assert.NotNull(uiNode);
+ Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue());
+ Assert.Equal("ui://weather/view.html", meta[McpApps.ResourceUriMetaKey]?.GetValue());
+ }
+
+ [Fact]
+ public void AppUi_WithVisibility_IncludesVisibilityInUiObject()
+ {
+ var tool = McpServerTool.Create(
+ (string location) => $"Weather for {location}",
+ new McpServerToolCreateOptions
+ {
+ Name = "get_weather",
+ AppUi = new McpUiToolMeta
+ {
+ ResourceUri = "ui://weather/view.html",
+ Visibility = [McpUiToolVisibility.Model],
+ },
+ });
+
+ var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject();
+ Assert.NotNull(uiNode);
+
+ var visibility = uiNode["visibility"]?.AsArray();
+ Assert.NotNull(visibility);
+ Assert.Single(visibility);
+ Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue());
+ }
+
+ [Fact]
+ public void AppUi_ExplicitMeta_TakesPrecedenceOver_AppUi()
+ {
+ var tool = McpServerTool.Create(
+ (string location) => $"Weather for {location}",
+ new McpServerToolCreateOptions
+ {
+ Name = "get_weather",
+ // Explicit Meta entry for "ui" should override AppUi
+ Meta = new JsonObject
+ {
+ ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/view.html" },
+ },
+ AppUi = new McpUiToolMeta { ResourceUri = "ui://app-ui/view.html" },
+ });
+
+ var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject();
+ // Explicit Meta["ui"] wins
+ Assert.Equal("ui://explicit/view.html", uiNode?["resourceUri"]?.GetValue());
+ }
+
+ [Fact]
+ public void AppUi_NullResourceUri_DoesNotPopulateLegacyKey()
+ {
+ // AppUi with no ResourceUri should not add the legacy flat key
+ var tool = McpServerTool.Create(
+ (string location) => $"Weather for {location}",
+ new McpServerToolCreateOptions
+ {
+ Name = "get_weather",
+ AppUi = new McpUiToolMeta { Visibility = [McpUiToolVisibility.App] },
+ });
+
+ Assert.Null(tool.ProtocolTool.Meta?[McpApps.ResourceUriMetaKey]);
+ }
+
+ [Fact]
+ public void AppUi_IsPreservedWhenOptionsAreClonedInDeriveOptions()
+ {
+ // DeriveOptions() calls options.Clone() internally when creating via MethodInfo.
+ // If AppUi is not included in Clone(), it would be lost when creating the tool via a method.
+ var appUi = new McpUiToolMeta { ResourceUri = "ui://weather/view.html" };
+ var options = new McpServerToolCreateOptions { AppUi = appUi };
+
+ // Use the MethodInfo path, which calls DeriveOptions -> options.Clone()
+ var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!;
+ var tool = McpServerTool.Create(method, target: null, options);
+
+ // The attribute on the method overrides options.AppUi, but both should produce the same meta.
+ var meta = tool.ProtocolTool.Meta;
+ Assert.NotNull(meta);
+ Assert.NotNull(meta["ui"]);
+ Assert.NotNull(meta[McpApps.ResourceUriMetaKey]);
+ }
+
+ #endregion
+
+ #region Test helper types
+
+ [McpServerToolType]
+ private static class TestToolsWithAppUi
+ {
+ [McpServerTool]
+ [McpAppUi(ResourceUri = "ui://weather/view.html")]
+ [Description("Get weather")]
+ public static string WeatherTool(string location) => $"Weather for {location}";
+
+ [McpServerTool]
+ [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])]
+ public static string ModelOnlyTool(string location) => $"Model only for {location}";
+
+ [McpServerTool]
+ [McpAppUi(ResourceUri = "ui://app-ui/view.html")]
+ [McpMeta("ui", JsonValue = """{"resourceUri": "ui://mcpmeta/view.html"}""")]
+ [McpMeta("extraKey", "extra-value")]
+ public static string ToolWithBothAttributes(string input) => input;
+ }
+
+ #endregion
+}