From 5fd0c8d5b5c598de3744c25943ab07a705829769 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 09:23:16 -0700 Subject: [PATCH 1/4] Add app-rendered elicitation support --- docs/concepts/apps/apps.md | 52 +++++ .../Server/McpAppElicitation.cs | 211 ++++++++++++++++++ .../Server/McpAppElicitationMeta.cs | 13 ++ .../Server/McpApps.cs | 32 +++ .../Server/McpAppsBuilderExtensions.cs | 5 +- .../Server/McpAppsJsonContext.cs | 3 + .../Server/McpUiClientCapabilities.cs | 7 + .../Server/McpUiElicitationCapability.cs | 9 + .../Server/McpUiServerCapabilities.cs | 16 ++ .../Server/McpAppElicitationTests.cs | 183 +++++++++++++++ .../Server/McpAppsTests.cs | 3 + 11 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiElicitationCapability.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiServerCapabilities.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index f22479966..958f037c6 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -158,6 +158,58 @@ public static string GetWeather(McpServer server, string location) } ``` +## App-rendered elicitations + +MCP Apps can render standard form-mode `elicitation/create` requests. This uses +the existing `io.modelcontextprotocol/ui` extension; it does not define another +extension or a separate result type. + +Call `WithMcpApps()` to advertise +`extensions["io.modelcontextprotocol/ui"].elicitation` from the server. A host +advertises the matching setting, the MCP Apps HTML MIME type, and the core form +elicitation capability: + +```csharp +var clientCapabilities = new ClientCapabilities(); +McpAppElicitation.AddClientCapabilities(clientCapabilities); +``` + +On the server, associate a form elicitation with an absolute `ui://` resource +only when the requesting client advertised all three client-side settings: + +```csharp +var elicitation = new ElicitRequestParams +{ + Message = "Choose a delivery window", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["window"] = new ElicitRequestParams.TitledSingleSelectEnumSchema + { + OneOf = + [ + new() { Const = "morning", Title = "Morning" }, + new() { Const = "afternoon", Title = "Afternoon" }, + ], + }, + }, + Required = ["window"], + }, +}; + +McpAppElicitation.SetAppUiIfSupported( + elicitation, + requestContext, + "ui://delivery/choose-window.html"); +``` + +This adds `_meta.ui.resourceUri` without changing the core elicitation request. +Unsupported clients receive the same request without UI metadata and can render +their native form. The host forwards the request to the selected app and +returns the app's standard `ElicitResult`; validation and MRTR retries remain +part of the core elicitation flow. + ## Display modes The MCP Apps spec defines display modes (`inline`, `fullscreen`, `pip`) that control how the host renders the UI. Display mode is negotiated between the client and server during capability exchange and is not set per-tool — it depends on the host implementation. diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs new file mode 100644 index 000000000..5de052f5e --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -0,0 +1,211 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Apps; + +/// Provides conventions for using MCP Apps as form elicitation UI. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public static class McpAppElicitation +{ + /// Adds the client capabilities required for app-rendered form elicitation. + public static ClientCapabilities AddClientCapabilities(ClientCapabilities capabilities) + { +#if NET + ArgumentNullException.ThrowIfNull(capabilities); +#else + if (capabilities is null) throw new ArgumentNullException(nameof(capabilities)); +#endif + + capabilities.Elicitation ??= new ElicitationCapability(); + capabilities.Elicitation.Form ??= new FormElicitationCapability(); + capabilities.Extensions ??= new Dictionary(); + + JsonObject uiCapabilities; + if (capabilities.Extensions.TryGetValue(McpApps.ExtensionId, out var existing)) + { + uiCapabilities = existing switch + { + McpUiClientCapabilities typed => JsonSerializer.SerializeToNode( + typed, + McpAppsJsonContext.Default.McpUiClientCapabilities)!.AsObject(), + JsonObject jsonObject => jsonObject, + JsonElement { ValueKind: JsonValueKind.Object } element => + JsonNode.Parse(element.GetRawText())!.AsObject(), + _ => [], + }; + } + else + { + uiCapabilities = []; + } + + if (uiCapabilities["mimeTypes"] is not JsonArray mimeTypes) + { + mimeTypes = []; + uiCapabilities["mimeTypes"] = mimeTypes; + } + + if (!mimeTypes.Any(node => + node is JsonValue value && + value.TryGetValue(out var mimeType) && + string.Equals(mimeType, McpApps.HtmlMimeType, StringComparison.OrdinalIgnoreCase))) + { + mimeTypes.Add((JsonNode?)JsonValue.Create(McpApps.HtmlMimeType)); + } + + uiCapabilities["elicitation"] ??= new JsonObject(); + capabilities.Extensions[McpApps.ExtensionId] = uiCapabilities; + return capabilities; + } + + /// + /// Returns whether both peers advertised app-rendered form elicitation + /// through the existing MCP Apps extension. + /// + public static bool IsSupported( + ClientCapabilities? clientCapabilities, + ServerCapabilities? serverCapabilities) + { + var serverUi = McpApps.GetUiServerCapability(serverCapabilities); + + return IsClientSupported(clientCapabilities) && + serverUi?.Elicitation is not null; + } + + /// + /// Returns whether a client advertised all capabilities required for + /// app-rendered form elicitation. + /// + /// + /// A server using this one-sided check must also advertise + /// io.modelcontextprotocol/ui.elicitation, as + /// does. + /// + public static bool IsClientSupported(ClientCapabilities? clientCapabilities) + { + var clientUi = McpApps.GetUiCapability(clientCapabilities); + return clientCapabilities?.Elicitation?.Form is not null && + clientUi?.Elicitation is not null && + clientUi.MimeTypes?.Contains(McpApps.HtmlMimeType, StringComparer.OrdinalIgnoreCase) == true; + } + + /// Associates a form elicitation request with an MCP App UI resource. + public static ElicitRequestParams SetAppUi(ElicitRequestParams request, string resourceUri) + { + ValidateArguments(request, resourceUri); + + request.Meta ??= []; + request.Meta["ui"] = JsonSerializer.SerializeToNode( + new McpAppElicitationMeta { ResourceUri = resourceUri }, + McpAppsJsonContext.Default.McpAppElicitationMeta); + return request; + } + + /// + /// Associates a form elicitation with an MCP App only when both peers + /// negotiated support. Otherwise the request remains a native elicitation. + /// + public static ElicitRequestParams SetAppUiIfSupported( + ElicitRequestParams request, + ClientCapabilities? clientCapabilities, + ServerCapabilities? serverCapabilities, + string resourceUri) + { + ValidateArguments(request, resourceUri); + return IsSupported(clientCapabilities, serverCapabilities) + ? SetAppUi(request, resourceUri) + : request; + } + + /// + /// Associates a form elicitation with an MCP App when the client negotiated + /// support. The server must advertise its side of the setting by calling + /// . + /// + public static ElicitRequestParams SetAppUiIfSupported( + ElicitRequestParams request, + ClientCapabilities? clientCapabilities, + string resourceUri) + { + ValidateArguments(request, resourceUri); + return IsClientSupported(clientCapabilities) + ? SetAppUi(request, resourceUri) + : request; + } + + /// + /// Associates a form elicitation with an MCP App using the requesting + /// client's request-scoped capabilities when available. + /// + public static ElicitRequestParams SetAppUiIfSupported( + ElicitRequestParams request, + RequestContext context, + string resourceUri) + { +#if NET + ArgumentNullException.ThrowIfNull(context); +#else + if (context is null) throw new ArgumentNullException(nameof(context)); +#endif + + var clientCapabilities = + context.JsonRpcRequest.Context?.ClientCapabilities ?? + context.Server.ClientCapabilities; + return SetAppUiIfSupported(request, clientCapabilities, resourceUri); + } + + /// Gets the MCP App UI metadata from an elicitation request. + public static McpAppElicitationMeta? GetAppUi(ElicitRequestParams request) + { +#if NET + ArgumentNullException.ThrowIfNull(request); +#else + if (request is null) throw new ArgumentNullException(nameof(request)); +#endif + + if (request.Meta?["ui"] is not JsonNode node) + { + return null; + } + + try + { + var result = node.Deserialize(McpAppsJsonContext.Default.McpAppElicitationMeta); + return result is not null && IsAbsoluteUiUri(result.ResourceUri) ? result : null; + } + catch (JsonException) + { + return null; + } + } + + private static void ValidateArguments(ElicitRequestParams request, string resourceUri) + { +#if NET + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceUri); +#else + if (request is null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(resourceUri)) throw new ArgumentException("The resource URI is required.", nameof(resourceUri)); +#endif + + if (!string.Equals(request.Mode, "form", StringComparison.Ordinal)) + { + throw new ArgumentException("MCP Apps only support form-mode elicitations.", nameof(request)); + } + + if (!IsAbsoluteUiUri(resourceUri)) + { + throw new ArgumentException( + "MCP App elicitation resources must be absolute ui:// URIs.", + nameof(resourceUri)); + } + } + + private static bool IsAbsoluteUiUri(string resourceUri) => + Uri.TryCreate(resourceUri, UriKind.Absolute, out var uri) && + string.Equals(uri.Scheme, "ui", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs new file mode 100644 index 000000000..f076cc326 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// Associates a form elicitation with the MCP App that should render it. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpAppElicitationMeta +{ + /// Gets or sets the absolute ui:// resource URI for the elicitation UI. + [JsonPropertyName("resourceUri")] + public required string ResourceUri { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs index 53de40759..3667a5760 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs @@ -111,6 +111,38 @@ private static JsonSerializerOptions CreateSerializerOptions() return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiClientCapabilities); } + if (value is JsonObject jsonObject) + { + return jsonObject.Deserialize(McpAppsJsonContext.Default.McpUiClientCapabilities); + } + + return null; + } + + /// Gets the MCP Apps capability advertised by a server. + public static McpUiServerCapabilities? GetUiServerCapability(ServerCapabilities? capabilities) + { + if (capabilities?.Extensions is not { } extensions || + !extensions.TryGetValue(ExtensionId, out var value)) + { + return null; + } + + if (value is McpUiServerCapabilities uiCapabilities) + { + return uiCapabilities; + } + + if (value is JsonObject jsonObject) + { + return jsonObject.Deserialize(McpAppsJsonContext.Default.McpUiServerCapabilities); + } + + if (value is JsonElement { ValueKind: JsonValueKind.Object } element) + { + return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiServerCapabilities); + } + return null; } diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs index a68d8fe50..2f2790626 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs @@ -56,7 +56,10 @@ public void PostConfigure(string? name, McpServerOptions options) options.Capabilities.Extensions ??= new Dictionary(); if (!options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)) { - options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject(); + options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject + { + ["elicitation"] = new System.Text.Json.Nodes.JsonObject(), + }; } if (options.ToolCollection is { IsEmpty: false } tools) diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs index 8a03ab95d..1e61b4696 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs @@ -14,6 +14,9 @@ namespace ModelContextProtocol.Extensions.Apps; [JsonSerializable(typeof(McpUiResourceMeta))] [JsonSerializable(typeof(McpUiResourceCsp))] [JsonSerializable(typeof(McpUiResourcePermissions))] +[JsonSerializable(typeof(McpUiElicitationCapability))] +[JsonSerializable(typeof(McpUiServerCapabilities))] +[JsonSerializable(typeof(McpAppElicitationMeta))] internal sealed partial class McpAppsJsonContext : JsonSerializerContext { } diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs index a446d90cb..3d48e3a26 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs @@ -23,4 +23,11 @@ public sealed class McpUiClientCapabilities /// [JsonPropertyName("mimeTypes")] public IList? MimeTypes { get; set; } + + /// + /// Gets or sets the capability indicating that the client can render core + /// form elicitations using an MCP App. + /// + [JsonPropertyName("elicitation")] + public McpUiElicitationCapability? Elicitation { get; set; } } diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiElicitationCapability.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiElicitationCapability.cs new file mode 100644 index 000000000..69e52c252 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiElicitationCapability.cs @@ -0,0 +1,9 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Extensions.Apps; + +/// Describes support for rendering form elicitations with MCP Apps. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiElicitationCapability +{ +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiServerCapabilities.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiServerCapabilities.cs new file mode 100644 index 000000000..fd36a9754 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiServerCapabilities.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps; + +/// Represents the MCP Apps capabilities advertised by a server. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpUiServerCapabilities +{ + /// + /// Gets or sets the capability indicating that the server may associate a + /// form elicitation with an MCP App resource. + /// + [JsonPropertyName("elicitation")] + public McpUiElicitationCapability? Elicitation { get; set; } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs new file mode 100644 index 000000000..d93ed5520 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -0,0 +1,183 @@ +#pragma warning disable MCPEXP003 + +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpAppElicitationTests +{ + [Fact] + public void AddClientCapabilities_AddsCoreAndExistingUiExtensionSettings() + { + var capabilities = new ClientCapabilities + { + Extensions = new Dictionary + { + [McpApps.ExtensionId] = new JsonObject + { + ["custom"] = true, + }, + }, + }; + + McpAppElicitation.AddClientCapabilities(capabilities); + + Assert.NotNull(capabilities.Elicitation?.Form); + var ui = Assert.IsType(capabilities.Extensions[McpApps.ExtensionId]); + Assert.True(ui["custom"]!.GetValue()); + Assert.NotNull(ui["elicitation"]); + Assert.Contains( + McpApps.HtmlMimeType, + ui["mimeTypes"]!.AsArray().Select(value => value!.GetValue()), + StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void IsSupported_RequiresBothPeersAndCoreFormCapability() + { + var client = CreateClientCapabilities(); + var server = CreateServerCapabilities(); + + Assert.True(McpAppElicitation.IsSupported(client, server)); + Assert.False(McpAppElicitation.IsSupported(new ClientCapabilities(), server)); + Assert.False(McpAppElicitation.IsSupported(client, new ServerCapabilities())); + + client.Elicitation = null; + Assert.False(McpAppElicitation.IsSupported(client, server)); + } + + [Fact] + public void SetAppUi_RoundTripsMetadataWithoutChangingCoreRequest() + { + var request = CreateRequest(); + + var result = McpAppElicitation.SetAppUi( + request, + "ui://example/choose-option.html"); + + Assert.Same(request, result); + Assert.Equal("Choose an option", result.Message); + Assert.Equal( + "ui://example/choose-option.html", + McpAppElicitation.GetAppUi(result)?.ResourceUri); + } + + [Fact] + public void SetAppUiIfSupported_FallsBackToUnmodifiedNativeRequest() + { + var request = CreateRequest(); + + var result = McpAppElicitation.SetAppUiIfSupported( + request, + new ClientCapabilities(), + CreateServerCapabilities(), + "ui://example/choose-option.html"); + + Assert.Same(request, result); + Assert.Null(result.Meta); + } + + [Fact] + public void SetAppUiIfSupported_AttachesUiWhenNegotiated() + { + var request = CreateRequest(); + + McpAppElicitation.SetAppUiIfSupported( + request, + CreateClientCapabilities(), + CreateServerCapabilities(), + "ui://example/choose-option.html"); + + Assert.Equal( + "ui://example/choose-option.html", + McpAppElicitation.GetAppUi(request)?.ResourceUri); + } + + [Fact] + public void SetAppUiIfSupported_ClientOverload_AttachesUiWhenClientSupportsIt() + { + var request = CreateRequest(); + + McpAppElicitation.SetAppUiIfSupported( + request, + CreateClientCapabilities(), + "ui://example/choose-option.html"); + + Assert.NotNull(McpAppElicitation.GetAppUi(request)); + } + + [Theory] + [InlineData("https://example.com/view.html")] + [InlineData("relative/view.html")] + [InlineData("")] + public void SetAppUi_RejectsInvalidResourceUri(string resourceUri) + { + Assert.ThrowsAny(() => + McpAppElicitation.SetAppUi(CreateRequest(), resourceUri)); + } + + [Fact] + public void SetAppUi_RejectsUrlMode() + { + var request = new ElicitRequestParams + { + Mode = "url", + Message = "Continue", + ElicitationId = "123", + Url = "https://example.com", + }; + + Assert.Throws(() => + McpAppElicitation.SetAppUi(request, "ui://example/view.html")); + } + + [Fact] + public void GetAppUi_ReturnsNullForMalformedOrNonUiMetadata() + { + var malformed = CreateRequest(); + malformed.Meta = new JsonObject { ["ui"] = "not-an-object" }; + Assert.Null(McpAppElicitation.GetAppUi(malformed)); + + var nonUi = CreateRequest(); + nonUi.Meta = new JsonObject + { + ["ui"] = new JsonObject + { + ["resourceUri"] = "https://example.com", + }, + }; + Assert.Null(McpAppElicitation.GetAppUi(nonUi)); + } + + private static ClientCapabilities CreateClientCapabilities() + { + var capabilities = new ClientCapabilities(); + return McpAppElicitation.AddClientCapabilities(capabilities); + } + + private static ServerCapabilities CreateServerCapabilities() => new() + { + Extensions = new Dictionary + { + [McpApps.ExtensionId] = new McpUiServerCapabilities + { + Elicitation = new McpUiElicitationCapability(), + }, + }, + }; + + private static ElicitRequestParams CreateRequest() => new() + { + Message = "Choose an option", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["choice"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["choice"], + }, + }; +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs index 756417de0..3f004e505 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs @@ -466,6 +466,9 @@ public void WithMcpApps_AdvertisesServerCapability() Assert.NotNull(options.Capabilities); Assert.NotNull(options.Capabilities.Extensions); Assert.True(options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)); + var uiCapabilities = Assert.IsType( + options.Capabilities.Extensions[McpApps.ExtensionId]); + Assert.NotNull(uiCapabilities["elicitation"]); } #endregion From 21d02388c3318277a2bd1461d4372269cec6d1aa Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 29 Jul 2026 10:53:06 -0700 Subject: [PATCH 2/4] docs: align app elicitations with MRTR --- docs/concepts/apps/apps.md | 25 +++++++++++++++---- .../Server/McpAppElicitation.cs | 4 ++- .../Server/McpApps.cs | 7 +++++- .../Server/McpAppElicitationTests.cs | 15 +++++++++++ 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index 958f037c6..efad87481 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -160,7 +160,11 @@ public static string GetWeather(McpServer server, string location) ## App-rendered elicitations -MCP Apps can render standard form-mode `elicitation/create` requests. This uses +MCP Apps can render standard form-mode `elicitation/create` requests. In +protocol revision `2026-07-28`, these requests are delivered through the +multi-round-trip request (MRTR) flow: the server returns an +`InputRequiredResult`, the client resolves its `inputRequests`, and then retries +the original operation with `inputResponses` and any `requestState`. This uses the existing `io.modelcontextprotocol/ui` extension; it does not define another extension or a separate result type. @@ -175,7 +179,10 @@ McpAppElicitation.AddClientCapabilities(clientCapabilities); ``` On the server, associate a form elicitation with an absolute `ui://` resource -only when the requesting client advertised all three client-side settings: +only when the requesting client advertised all three client-side settings. The +`RequestContext` overload uses the request-scoped capabilities required by +`2026-07-28` and falls back to initialized session capabilities on older +protocol revisions: ```csharp var elicitation = new ElicitRequestParams @@ -202,13 +209,21 @@ McpAppElicitation.SetAppUiIfSupported( elicitation, requestContext, "ui://delivery/choose-window.html"); + +throw new InputRequiredException( + inputRequests: new Dictionary + { + ["delivery-window"] = InputRequest.ForElicitation(elicitation), + }, + requestState: "schedule-delivery:v1"); ``` This adds `_meta.ui.resourceUri` without changing the core elicitation request. Unsupported clients receive the same request without UI metadata and can render -their native form. The host forwards the request to the selected app and -returns the app's standard `ElicitResult`; validation and MRTR retries remain -part of the core elicitation flow. +their native form. The host forwards the embedded request to the selected app, +validates the app's standard `ElicitResult`, places it under `delivery-window` +in `inputResponses`, and retries the original operation with +`schedule-delivery:v1` as `requestState`. ## Display modes diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs index 5de052f5e..5a2c29e1d 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -138,7 +138,9 @@ public static ElicitRequestParams SetAppUiIfSupported( /// /// Associates a form elicitation with an MCP App using the requesting - /// client's request-scoped capabilities when available. + /// client's request-scoped capabilities when available. Protocol revision + /// 2026-07-28 supplies these capabilities on every request; older + /// revisions fall back to the initialized session capabilities. /// public static ElicitRequestParams SetAppUiIfSupported( ElicitRequestParams request, diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs index 3667a5760..e2e2232bd 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs @@ -69,7 +69,12 @@ private static JsonSerializerOptions CreateSerializerOptions() /// /// Gets the MCP Apps client capability, if advertised by the connected client. /// - /// The client capabilities received during the MCP initialize handshake. + /// + /// The effective client capabilities for the request. In protocol revision + /// 2026-07-28, use the request-scoped capabilities from + /// ; on older revisions, + /// use the capabilities negotiated during initialization. + /// /// /// A instance if the client advertises support for the MCP Apps extension; /// otherwise, . diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs index d93ed5520..ed2895428 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -64,6 +64,21 @@ public void SetAppUi_RoundTripsMetadataWithoutChangingCoreRequest() McpAppElicitation.GetAppUi(result)?.ResourceUri); } + [Fact] + public void SetAppUi_RoundTripsThroughMrtrInputRequest() + { + var request = McpAppElicitation.SetAppUi( + CreateRequest(), + "ui://example/choose-option.html"); + + var embedded = InputRequest.ForElicitation(request).ElicitationParams; + + Assert.NotNull(embedded); + Assert.Equal( + "ui://example/choose-option.html", + McpAppElicitation.GetAppUi(embedded)?.ResourceUri); + } + [Fact] public void SetAppUiIfSupported_FallsBackToUnmodifiedNativeRequest() { From ea6d1eed980c75a39f8fd64c7ddeff3c4840fa90 Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Mon, 3 Aug 2026 11:41:33 -0700 Subject: [PATCH 3/4] Validate MCP App elicitation results Add an AOT-safe host validation helper for accepted app-rendered form results, including defaults and actionable value-free errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa0e1618-463c-40d3-a1c7-2563c23e8c42 --- docs/concepts/apps/apps.md | 37 +- ...odelContextProtocol.Extensions.Apps.csproj | 8 +- .../Server/McpAppElicitation.Validation.cs | 487 ++++++++++++++++++ .../Server/McpAppElicitation.cs | 2 +- .../Server/McpAppsJsonContext.cs | 4 + .../Server/McpAppElicitationTests.cs | 368 +++++++++++++ 6 files changed, 900 insertions(+), 6 deletions(-) create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.Validation.cs diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index efad87481..7c61ff13f 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -220,10 +220,39 @@ throw new InputRequiredException( This adds `_meta.ui.resourceUri` without changing the core elicitation request. Unsupported clients receive the same request without UI metadata and can render -their native form. The host forwards the embedded request to the selected app, -validates the app's standard `ElicitResult`, places it under `delivery-window` -in `inputResponses`, and retries the original operation with -`schedule-delivery:v1` as `requestState`. +their native form. + +The host forwards the embedded request to the selected app, validates the +app's standard `ElicitResult` against the original request, and only then +places the normalized result in the MRTR response: + +```csharp +ElicitResult appResult = + await appBridge.ElicitAsync(elicitation, cancellationToken); + +var validation = + McpAppElicitation.ValidateResult(elicitation, appResult); + +if (validation.ValidatedResult is not { } validatedResult) +{ + // Errors contain schema paths and value-free messages suitable for diagnostics. + throw new InvalidOperationException( + string.Join("; ", validation.Errors.Select( + error => $"{error.Path}: {error.Message}"))); +} + +var inputResponses = new Dictionary +{ + ["delivery-window"] = InputResponse.FromElicitResult(validatedResult), +}; + +// Retry the original operation with inputResponses and +// requestState: "schedule-delivery:v1". +``` + +Only accepted results are schema-validated. Decline and cancel remain valid +standard outcomes without content. Missing fields with schema defaults are +populated in `ValidatedResult`; submitted values are never coerced. ## Display modes diff --git a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj index bd14370d5..8be6a1f01 100644 --- a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj +++ b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj @@ -26,11 +26,17 @@ - + + + + diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.Validation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.Validation.cs new file mode 100644 index 000000000..6ab0f518e --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.Validation.cs @@ -0,0 +1,487 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Net.Mail; +using System.Text.Json; + +namespace ModelContextProtocol.Extensions.Apps; + +public static partial class McpAppElicitation +{ + /// + /// Validates a standard elicitation result against the original form request. + /// + /// The original form elicitation request. + /// The result returned by an MCP App or another untrusted form renderer. + /// + /// A validation result containing a normalized elicitation result when valid, or actionable + /// validation errors that do not include submitted values. + /// + /// + /// + /// Accepted results are validated against . + /// Missing properties with schema defaults are populated before validation, matching the core + /// SDK's elicitation default behavior. Submitted values are never coerced. + /// + /// + /// Declined and cancelled results are valid without content and are returned unchanged. + /// + /// + /// or is . + /// is not a form elicitation with a requested schema. + public static McpAppElicitationValidationResult ValidateResult( + ElicitRequestParams request, + ElicitResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(result); +#else + if (request is null) throw new ArgumentNullException(nameof(request)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + if (!string.Equals(request.Mode, "form", StringComparison.Ordinal) || + request.RequestedSchema is not { } requestedSchema) + { + throw new ArgumentException( + "Result validation requires a form elicitation with a requested schema.", + nameof(request)); + } + + if (!IsStandardAction(result.Action)) + { + return McpAppElicitationValidationResult.Invalid( + new McpAppElicitationValidationError( + "/action", + "Action must be 'accept', 'decline', or 'cancel'.")); + } + + if (!result.IsAccepted) + { + return McpAppElicitationValidationResult.Valid(result); + } + + var content = result.Content is not null ? + new Dictionary(result.Content, StringComparer.Ordinal) : + new Dictionary(StringComparer.Ordinal); + + ApplyDefaults(requestedSchema.Properties, content); + + List errors = []; + if (result.Content is null && content.Count == 0) + { + errors.Add(new( + "/content", + "Accepted elicitation results must include content.")); + } + + if (requestedSchema.Required is { } required) + { + foreach (string propertyName in required) + { + if (!content.ContainsKey(propertyName)) + { + errors.Add(new( + GetPropertyPath(propertyName), + "Required property is missing.")); + } + } + } + + foreach (KeyValuePair property in content) + { + if (!requestedSchema.Properties.TryGetValue(property.Key, out var propertySchema)) + { + errors.Add(new( + GetPropertyPath(property.Key), + "Property is not declared by the requested schema.")); + continue; + } + + ValidateProperty( + GetPropertyPath(property.Key), + property.Value, + propertySchema, + errors); + } + + if (errors.Count > 0) + { + return McpAppElicitationValidationResult.Invalid(errors); + } + + return McpAppElicitationValidationResult.Valid(new ElicitResult + { + Action = result.Action, + Content = content, + Meta = result.Meta, + }); + } + + private static void ApplyDefaults( + IDictionary properties, + IDictionary content) + { + foreach (KeyValuePair property in properties) + { + if (content.ContainsKey(property.Key) || + GetDefaultValue(property.Value) is not { } defaultValue) + { + continue; + } + + content[property.Key] = defaultValue; + } + } + + private static JsonElement? GetDefaultValue( + ElicitRequestParams.PrimitiveSchemaDefinition schema) => + schema switch + { + ElicitRequestParams.StringSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.String), + ElicitRequestParams.NumberSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.Double), + ElicitRequestParams.BooleanSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.Boolean), + ElicitRequestParams.UntitledSingleSelectEnumSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.String), + ElicitRequestParams.TitledSingleSelectEnumSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.String), + ElicitRequestParams.UntitledMultiSelectEnumSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.IListString), + ElicitRequestParams.TitledMultiSelectEnumSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.IListString), +#pragma warning disable MCP9001 + ElicitRequestParams.LegacyTitledEnumSchema { Default: { } value } => + JsonSerializer.SerializeToElement(value, McpAppsJsonContext.Default.String), +#pragma warning restore MCP9001 + _ => null, + }; + + private static void ValidateProperty( + string path, + JsonElement value, + ElicitRequestParams.PrimitiveSchemaDefinition schema, + ICollection errors) + { + switch (schema) + { + case ElicitRequestParams.StringSchema stringSchema: + ValidateString(path, value, stringSchema, errors); + break; + + case ElicitRequestParams.NumberSchema numberSchema: + ValidateNumber(path, value, numberSchema, errors); + break; + + case ElicitRequestParams.BooleanSchema: + if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + errors.Add(new(path, "Expected a boolean.")); + } + break; + + case ElicitRequestParams.UntitledSingleSelectEnumSchema enumSchema: + ValidateSingleSelect(path, value, enumSchema.Enum, errors); + break; + + case ElicitRequestParams.TitledSingleSelectEnumSchema enumSchema: + ValidateSingleSelect(path, value, enumSchema.OneOf.Select(option => option.Const), errors); + break; + + case ElicitRequestParams.UntitledMultiSelectEnumSchema enumSchema: + ValidateMultiSelect( + path, + value, + enumSchema.Items.Enum, + enumSchema.MinItems, + enumSchema.MaxItems, + errors); + break; + + case ElicitRequestParams.TitledMultiSelectEnumSchema enumSchema: + ValidateMultiSelect( + path, + value, + enumSchema.Items.AnyOf.Select(option => option.Const), + enumSchema.MinItems, + enumSchema.MaxItems, + errors); + break; + +#pragma warning disable MCP9001 + case ElicitRequestParams.LegacyTitledEnumSchema enumSchema: +#pragma warning restore MCP9001 + ValidateSingleSelect(path, value, enumSchema.Enum, errors); + break; + + default: + errors.Add(new(path, "The requested schema type is not supported.")); + break; + } + } + + private static void ValidateString( + string path, + JsonElement value, + ElicitRequestParams.StringSchema schema, + ICollection errors) + { + if (value.ValueKind is not JsonValueKind.String) + { + errors.Add(new(path, "Expected a string.")); + return; + } + + string stringValue = value.GetString()!; + int length = GetUnicodeScalarLength(stringValue); + if (schema.MinLength is { } minLength && length < minLength) + { + errors.Add(new(path, $"String length must be at least {minLength} characters.")); + } + + if (schema.MaxLength is { } maxLength && length > maxLength) + { + errors.Add(new(path, $"String length must be at most {maxLength} characters.")); + } + + if (schema.Format is { } format && !MatchesFormat(stringValue, format)) + { + errors.Add(new(path, $"String must match the '{format}' format.")); + } + } + + private static void ValidateNumber( + string path, + JsonElement value, + ElicitRequestParams.NumberSchema schema, + ICollection errors) + { + string expectedType = string.Equals(schema.Type, "integer", StringComparison.Ordinal) ? + "an integer" : + "a number"; + + if (value.ValueKind is not JsonValueKind.Number || + !value.TryGetDouble(out double number) || + double.IsNaN(number) || + double.IsInfinity(number) || + (string.Equals(schema.Type, "integer", StringComparison.Ordinal) && Math.Truncate(number) != number)) + { + errors.Add(new(path, $"Expected {expectedType}.")); + return; + } + + if (schema.Minimum is { } minimum && number < minimum) + { + errors.Add(new(path, $"Number must be greater than or equal to {minimum.ToString(CultureInfo.InvariantCulture)}.")); + } + + if (schema.Maximum is { } maximum && number > maximum) + { + errors.Add(new(path, $"Number must be less than or equal to {maximum.ToString(CultureInfo.InvariantCulture)}.")); + } + } + + private static void ValidateSingleSelect( + string path, + JsonElement value, + IEnumerable choices, + ICollection errors) + { + if (value.ValueKind is not JsonValueKind.String) + { + errors.Add(new(path, "Expected a string choice.")); + return; + } + + string selectedValue = value.GetString()!; + if (!choices.Contains(selectedValue, StringComparer.Ordinal)) + { + errors.Add(new(path, "Value is not one of the allowed choices.")); + } + } + + private static void ValidateMultiSelect( + string path, + JsonElement value, + IEnumerable choices, + int? minItems, + int? maxItems, + ICollection errors) + { + if (value.ValueKind is not JsonValueKind.Array) + { + errors.Add(new(path, "Expected an array of string choices.")); + return; + } + + HashSet allowedChoices = new(choices, StringComparer.Ordinal); + int itemCount = value.GetArrayLength(); + if (minItems is { } minimum && itemCount < minimum) + { + errors.Add(new(path, $"At least {minimum} choices must be selected.")); + } + + if (maxItems is { } maximum && itemCount > maximum) + { + errors.Add(new(path, $"At most {maximum} choices may be selected.")); + } + + int index = 0; + foreach (JsonElement item in value.EnumerateArray()) + { + string itemPath = $"{path}/{index}"; + if (item.ValueKind is not JsonValueKind.String) + { + errors.Add(new(itemPath, "Expected a string choice.")); + } + else if (!allowedChoices.Contains(item.GetString()!)) + { + errors.Add(new(itemPath, "Value is not one of the allowed choices.")); + } + + index++; + } + } + + private static bool MatchesFormat(string value, string format) => + format switch + { + "email" => IsValidEmail(value), + "uri" => Uri.TryCreate(value, UriKind.Absolute, out _), + "date" => DateTime.TryParseExact( + value, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _), + "date-time" => IsValidDateTime(value), + _ => true, + }; + + private static bool IsValidEmail(string value) + { + try + { + var address = new MailAddress(value); + return string.Equals(address.Address, value, StringComparison.OrdinalIgnoreCase); + } + catch (FormatException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + } + + private static bool IsValidDateTime(string value) + { + if (value.Length < 20 || value[10] is not ('T' or 't')) + { + return false; + } + + bool hasUtcDesignator = value[value.Length - 1] is 'Z' or 'z'; + bool hasOffset = + value.Length >= 25 && + value[value.Length - 6] is '+' or '-' && + value[value.Length - 3] == ':'; + if (!hasUtcDesignator && !hasOffset) + { + return false; + } + + string normalized = value + .Replace('t', 'T') + .Replace('z', 'Z'); + return DateTimeOffset.TryParseExact( + normalized, + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _); + } + + private static int GetUnicodeScalarLength(string value) + { + int length = 0; + for (int i = 0; i < value.Length; i++) + { + if (char.IsHighSurrogate(value[i]) && + i + 1 < value.Length && + char.IsLowSurrogate(value[i + 1])) + { + i++; + } + + length++; + } + + return length; + } + + private static bool IsStandardAction(string action) => + string.Equals(action, "accept", StringComparison.OrdinalIgnoreCase) || + string.Equals(action, "decline", StringComparison.OrdinalIgnoreCase) || + string.Equals(action, "cancel", StringComparison.OrdinalIgnoreCase); + + private static string GetPropertyPath(string propertyName) => + $"/content/{propertyName.Replace("~", "~0").Replace("/", "~1")}"; +} + +/// Represents the outcome of validating an MCP App elicitation result. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpAppElicitationValidationResult +{ + private McpAppElicitationValidationResult( + ElicitResult? validatedResult, + IReadOnlyList errors) + { + ValidatedResult = validatedResult; + Errors = errors; + } + + /// Gets whether the elicitation result is valid. + public bool IsValid => Errors.Count == 0; + + /// + /// Gets the validated result, including any schema defaults applied to accepted content, + /// or when validation failed. + /// + public ElicitResult? ValidatedResult { get; } + + /// Gets the validation errors. Submitted values are not included. + public IReadOnlyList Errors { get; } + + internal static McpAppElicitationValidationResult Valid(ElicitResult result) => + new(result, []); + + internal static McpAppElicitationValidationResult Invalid( + McpAppElicitationValidationError error) => + new(null, [error]); + + internal static McpAppElicitationValidationResult Invalid( + IReadOnlyList errors) => + new(null, errors); +} + +/// Describes one MCP App elicitation validation failure. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public sealed class McpAppElicitationValidationError +{ + internal McpAppElicitationValidationError(string path, string message) + { + Path = path; + Message = message; + } + + /// Gets the JSON Pointer path to the invalid result member. + public string Path { get; } + + /// Gets an actionable error message that does not include the submitted value. + public string Message { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs index 5a2c29e1d..cb1dc4730 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Extensions.Apps; /// Provides conventions for using MCP Apps as form elicitation UI. [Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] -public static class McpAppElicitation +public static partial class McpAppElicitation { /// Adds the client capabilities required for app-rendered form elicitation. public static ClientCapabilities AddClientCapabilities(ClientCapabilities capabilities) diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs index 1e61b4696..c258ad483 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs @@ -17,6 +17,10 @@ namespace ModelContextProtocol.Extensions.Apps; [JsonSerializable(typeof(McpUiElicitationCapability))] [JsonSerializable(typeof(McpUiServerCapabilities))] [JsonSerializable(typeof(McpAppElicitationMeta))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(IList))] internal sealed partial class McpAppsJsonContext : JsonSerializerContext { } diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs index ed2895428..2fe7e77e9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; @@ -166,6 +167,370 @@ public void GetAppUi_ReturnsNullForMalformedOrNonUiMetadata() Assert.Null(McpAppElicitation.GetAppUi(nonUi)); } + [Fact] + public void ValidateResult_AcceptedValidContent_ReturnsNormalizedResult() + { + var request = new ElicitRequestParams + { + Message = "Enter delivery details", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["email"] = new ElicitRequestParams.StringSchema + { + Format = "email", + MinLength = 6, + MaxLength = 100, + }, + ["quantity"] = new ElicitRequestParams.NumberSchema + { + Type = "integer", + Minimum = 1, + Maximum = 10, + }, + ["expedited"] = new ElicitRequestParams.BooleanSchema(), + ["window"] = new ElicitRequestParams.TitledSingleSelectEnumSchema + { + OneOf = + [ + new() { Const = "morning", Title = "Morning" }, + new() { Const = "afternoon", Title = "Afternoon" }, + ], + }, + ["days"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema + { + MinItems = 1, + MaxItems = 2, + Items = new() + { + Enum = ["monday", "tuesday", "wednesday"], + }, + }, + }, + Required = ["email", "quantity", "expedited", "window", "days"], + }, + }; + var result = new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["email"] = Json("\"person@example.com\""), + ["quantity"] = Json("2"), + ["expedited"] = Json("true"), + ["window"] = Json("\"morning\""), + ["days"] = Json("""["monday", "tuesday"]"""), + }, + }; + + var validation = McpAppElicitation.ValidateResult(request, result); + + Assert.True(validation.IsValid); + Assert.Empty(validation.Errors); + Assert.NotNull(validation.ValidatedResult); + Assert.Equal(2, validation.ValidatedResult.Content!["quantity"].GetInt32()); + } + + [Fact] + public void ValidateResult_MissingRequiredProperty_ReturnsError() + { + var validation = McpAppElicitation.ValidateResult( + CreateRequest(), + new ElicitResult + { + Action = "accept", + Content = new Dictionary(), + }); + + var error = Assert.Single(validation.Errors); + Assert.False(validation.IsValid); + Assert.Null(validation.ValidatedResult); + Assert.Equal("/content/choice", error.Path); + Assert.Equal("Required property is missing.", error.Message); + } + + [Fact] + public void ValidateResult_UnexpectedProperty_ReturnsError() + { + var validation = McpAppElicitation.ValidateResult( + CreateRequest(), + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["choice"] = Json("\"morning\""), + ["untrusted"] = Json("\"sensitive-value\""), + }, + }); + + var error = Assert.Single(validation.Errors); + Assert.Equal("/content/untrusted", error.Path); + Assert.DoesNotContain("sensitive-value", error.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("\"two\"")] + [InlineData("2.5")] + [InlineData("null")] + public void ValidateResult_InvalidIntegerValue_ReturnsError(string json) + { + var request = new ElicitRequestParams + { + Message = "Enter a quantity", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["quantity"] = new ElicitRequestParams.NumberSchema { Type = "integer" }, + }, + Required = ["quantity"], + }, + }; + + var validation = McpAppElicitation.ValidateResult( + request, + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["quantity"] = Json(json), + }, + }); + + var error = Assert.Single(validation.Errors); + Assert.Equal("/content/quantity", error.Path); + Assert.Equal("Expected an integer.", error.Message); + } + + [Fact] + public void ValidateResult_InvalidEnumChoices_ReturnErrorsWithoutValues() + { + var request = new ElicitRequestParams + { + Message = "Choose options", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["window"] = new ElicitRequestParams.TitledSingleSelectEnumSchema + { + OneOf = [new() { Const = "morning", Title = "Morning" }], + }, + ["days"] = new ElicitRequestParams.TitledMultiSelectEnumSchema + { + Items = new() + { + AnyOf = [new() { Const = "monday", Title = "Monday" }], + }, + }, + }, + }, + }; + + var validation = McpAppElicitation.ValidateResult( + request, + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["window"] = Json("\"secret-window\""), + ["days"] = Json("""["secret-day"]"""), + }, + }); + + Assert.False(validation.IsValid); + Assert.Collection( + validation.Errors, + error => + { + Assert.Equal("/content/window", error.Path); + Assert.DoesNotContain("secret-window", error.Message, StringComparison.Ordinal); + }, + error => + { + Assert.Equal("/content/days/0", error.Path); + Assert.DoesNotContain("secret-day", error.Message, StringComparison.Ordinal); + }); + } + + [Fact] + public void ValidateResult_LengthNumericAndSelectionBounds_ReturnErrors() + { + var request = new ElicitRequestParams + { + Message = "Enter bounded values", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema { MinLength = 2, MaxLength = 4 }, + ["score"] = new ElicitRequestParams.NumberSchema { Minimum = 1, Maximum = 5 }, + ["choices"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema + { + MinItems = 2, + MaxItems = 3, + Items = new() { Enum = ["a", "b", "c"] }, + }, + }, + }, + }; + + var validation = McpAppElicitation.ValidateResult( + request, + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["name"] = Json("\"x\""), + ["score"] = Json("6"), + ["choices"] = Json("""["a"]"""), + }, + }); + + Assert.False(validation.IsValid); + Assert.Equal( + ["/content/name", "/content/score", "/content/choices"], + validation.Errors.Select(error => error.Path)); + } + + [Theory] + [InlineData("", "email")] + [InlineData("not-an-email", "email")] + [InlineData("relative/path", "uri")] + [InlineData("2026-02-30", "date")] + [InlineData("2026-08-03 11:22:30Z", "date-time")] + public void ValidateResult_InvalidStringFormat_ReturnsError(string value, string format) + { + var request = new ElicitRequestParams + { + Message = "Enter a formatted value", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["value"] = new ElicitRequestParams.StringSchema { Format = format }, + }, + }, + }; + + var validation = McpAppElicitation.ValidateResult( + request, + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement(value, McpApps.SerializerOptions), + }, + }); + + var error = Assert.Single(validation.Errors); + Assert.Equal("/content/value", error.Path); + if (value.Length > 0) + { + Assert.DoesNotContain(value, error.Message, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData("person@example.com", "email")] + [InlineData("https://example.com/path", "uri")] + [InlineData("2026-08-03", "date")] + [InlineData("2026-08-03T11:22:30Z", "date-time")] + [InlineData("2026-08-03T11:22:30.462-07:00", "date-time")] + public void ValidateResult_ValidStringFormat_IsAccepted(string value, string format) + { + var request = new ElicitRequestParams + { + Message = "Enter a formatted value", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["value"] = new ElicitRequestParams.StringSchema { Format = format }, + }, + }, + }; + + var validation = McpAppElicitation.ValidateResult( + request, + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement(value, McpApps.SerializerOptions), + }, + }); + + Assert.True(validation.IsValid); + } + + [Theory] + [InlineData("decline")] + [InlineData("cancel")] + public void ValidateResult_DeclineAndCancelWithoutContent_AreValid(string action) + { + var result = new ElicitResult { Action = action }; + + var validation = McpAppElicitation.ValidateResult(CreateRequest(), result); + + Assert.True(validation.IsValid); + Assert.Same(result, validation.ValidatedResult); + Assert.Empty(validation.Errors); + } + + [Fact] + public void ValidateResult_AcceptedResult_AppliesDefaultsBeforeRequiredValidation() + { + var request = new ElicitRequestParams + { + Message = "Confirm defaults", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["window"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema + { + Enum = ["morning", "afternoon"], + Default = "morning", + }, + ["expedited"] = new ElicitRequestParams.BooleanSchema { Default = false }, + }, + Required = ["window"], + }, + }; + + var validation = McpAppElicitation.ValidateResult( + request, + new ElicitResult { Action = "accept" }); + + Assert.True(validation.IsValid); + Assert.Equal("morning", validation.ValidatedResult!.Content!["window"].GetString()); + Assert.False(validation.ValidatedResult.Content["expedited"].GetBoolean()); + } + + [Fact] + public void ValidateResult_AcceptedResultWithoutContentOrDefaults_ReturnsError() + { + var validation = McpAppElicitation.ValidateResult( + new ElicitRequestParams + { + Message = "Optional form", + RequestedSchema = new ElicitRequestParams.RequestSchema(), + }, + new ElicitResult { Action = "accept" }); + + var error = Assert.Single(validation.Errors); + Assert.Equal("/content", error.Path); + Assert.Equal("Accepted elicitation results must include content.", error.Message); + } + private static ClientCapabilities CreateClientCapabilities() { var capabilities = new ClientCapabilities(); @@ -195,4 +560,7 @@ private static ClientCapabilities CreateClientCapabilities() Required = ["choice"], }, }; + + private static JsonElement Json(string json) => + JsonDocument.Parse(json).RootElement.Clone(); } From 989f36eea5af917c1feb79149ea1de4dee4d316f Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Mon, 3 Aug 2026 16:19:50 -0700 Subject: [PATCH 4/4] Fix MCP Apps elicitation helpers Require hierarchical ui:// resource URIs and merge elicitation into existing typed and JSON MCP Apps server capabilities without losing existing settings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 19b7c141-8708-451b-b194-0c029b8e79a1 --- .../Server/McpAppElicitation.cs | 86 +++++--- .../Server/McpAppsBuilderExtensions.cs | 35 ++- .../Server/McpAppElicitationTests.cs | 152 +++++++------ .../Server/McpAppsTests.cs | 205 +++++++++++++----- 4 files changed, 319 insertions(+), 159 deletions(-) diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs index cb1dc4730..2d6f2af28 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -1,8 +1,8 @@ -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace ModelContextProtocol.Extensions.Apps; @@ -16,7 +16,8 @@ public static ClientCapabilities AddClientCapabilities(ClientCapabilities capabi #if NET ArgumentNullException.ThrowIfNull(capabilities); #else - if (capabilities is null) throw new ArgumentNullException(nameof(capabilities)); + if (capabilities is null) + throw new ArgumentNullException(nameof(capabilities)); #endif capabilities.Elicitation ??= new ElicitationCapability(); @@ -28,12 +29,13 @@ public static ClientCapabilities AddClientCapabilities(ClientCapabilities capabi { uiCapabilities = existing switch { - McpUiClientCapabilities typed => JsonSerializer.SerializeToNode( - typed, - McpAppsJsonContext.Default.McpUiClientCapabilities)!.AsObject(), + McpUiClientCapabilities typed => JsonSerializer + .SerializeToNode(typed, McpAppsJsonContext.Default.McpUiClientCapabilities)! + .AsObject(), JsonObject jsonObject => jsonObject, - JsonElement { ValueKind: JsonValueKind.Object } element => - JsonNode.Parse(element.GetRawText())!.AsObject(), + JsonElement { ValueKind: JsonValueKind.Object } element => JsonNode + .Parse(element.GetRawText())! + .AsObject(), _ => [], }; } @@ -48,10 +50,13 @@ public static ClientCapabilities AddClientCapabilities(ClientCapabilities capabi uiCapabilities["mimeTypes"] = mimeTypes; } - if (!mimeTypes.Any(node => - node is JsonValue value && - value.TryGetValue(out var mimeType) && - string.Equals(mimeType, McpApps.HtmlMimeType, StringComparison.OrdinalIgnoreCase))) + if ( + !mimeTypes.Any(node => + node is JsonValue value + && value.TryGetValue(out var mimeType) + && string.Equals(mimeType, McpApps.HtmlMimeType, StringComparison.OrdinalIgnoreCase) + ) + ) { mimeTypes.Add((JsonNode?)JsonValue.Create(McpApps.HtmlMimeType)); } @@ -67,12 +72,12 @@ node is JsonValue value && /// public static bool IsSupported( ClientCapabilities? clientCapabilities, - ServerCapabilities? serverCapabilities) + ServerCapabilities? serverCapabilities + ) { var serverUi = McpApps.GetUiServerCapability(serverCapabilities); - return IsClientSupported(clientCapabilities) && - serverUi?.Elicitation is not null; + return IsClientSupported(clientCapabilities) && serverUi?.Elicitation is not null; } /// @@ -87,9 +92,10 @@ public static bool IsSupported( public static bool IsClientSupported(ClientCapabilities? clientCapabilities) { var clientUi = McpApps.GetUiCapability(clientCapabilities); - return clientCapabilities?.Elicitation?.Form is not null && - clientUi?.Elicitation is not null && - clientUi.MimeTypes?.Contains(McpApps.HtmlMimeType, StringComparer.OrdinalIgnoreCase) == true; + return clientCapabilities?.Elicitation?.Form is not null + && clientUi?.Elicitation is not null + && clientUi.MimeTypes?.Contains(McpApps.HtmlMimeType, StringComparer.OrdinalIgnoreCase) + == true; } /// Associates a form elicitation request with an MCP App UI resource. @@ -100,7 +106,8 @@ public static ElicitRequestParams SetAppUi(ElicitRequestParams request, string r request.Meta ??= []; request.Meta["ui"] = JsonSerializer.SerializeToNode( new McpAppElicitationMeta { ResourceUri = resourceUri }, - McpAppsJsonContext.Default.McpAppElicitationMeta); + McpAppsJsonContext.Default.McpAppElicitationMeta + ); return request; } @@ -112,7 +119,8 @@ public static ElicitRequestParams SetAppUiIfSupported( ElicitRequestParams request, ClientCapabilities? clientCapabilities, ServerCapabilities? serverCapabilities, - string resourceUri) + string resourceUri + ) { ValidateArguments(request, resourceUri); return IsSupported(clientCapabilities, serverCapabilities) @@ -128,12 +136,11 @@ public static ElicitRequestParams SetAppUiIfSupported( public static ElicitRequestParams SetAppUiIfSupported( ElicitRequestParams request, ClientCapabilities? clientCapabilities, - string resourceUri) + string resourceUri + ) { ValidateArguments(request, resourceUri); - return IsClientSupported(clientCapabilities) - ? SetAppUi(request, resourceUri) - : request; + return IsClientSupported(clientCapabilities) ? SetAppUi(request, resourceUri) : request; } /// @@ -145,17 +152,18 @@ public static ElicitRequestParams SetAppUiIfSupported( public static ElicitRequestParams SetAppUiIfSupported( ElicitRequestParams request, RequestContext context, - string resourceUri) + string resourceUri + ) { #if NET ArgumentNullException.ThrowIfNull(context); #else - if (context is null) throw new ArgumentNullException(nameof(context)); + if (context is null) + throw new ArgumentNullException(nameof(context)); #endif var clientCapabilities = - context.JsonRpcRequest.Context?.ClientCapabilities ?? - context.Server.ClientCapabilities; + context.JsonRpcRequest.Context?.ClientCapabilities ?? context.Server.ClientCapabilities; return SetAppUiIfSupported(request, clientCapabilities, resourceUri); } @@ -165,7 +173,8 @@ public static ElicitRequestParams SetAppUiIfSupported( #if NET ArgumentNullException.ThrowIfNull(request); #else - if (request is null) throw new ArgumentNullException(nameof(request)); + if (request is null) + throw new ArgumentNullException(nameof(request)); #endif if (request.Meta?["ui"] is not JsonNode node) @@ -190,24 +199,31 @@ private static void ValidateArguments(ElicitRequestParams request, string resour ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(resourceUri); #else - if (request is null) throw new ArgumentNullException(nameof(request)); - if (string.IsNullOrWhiteSpace(resourceUri)) throw new ArgumentException("The resource URI is required.", nameof(resourceUri)); + if (request is null) + throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(resourceUri)) + throw new ArgumentException("The resource URI is required.", nameof(resourceUri)); #endif if (!string.Equals(request.Mode, "form", StringComparison.Ordinal)) { - throw new ArgumentException("MCP Apps only support form-mode elicitations.", nameof(request)); + throw new ArgumentException( + "MCP Apps only support form-mode elicitations.", + nameof(request) + ); } if (!IsAbsoluteUiUri(resourceUri)) { throw new ArgumentException( "MCP App elicitation resources must be absolute ui:// URIs.", - nameof(resourceUri)); + nameof(resourceUri) + ); } } private static bool IsAbsoluteUiUri(string resourceUri) => - Uri.TryCreate(resourceUri, UriKind.Absolute, out var uri) && - string.Equals(uri.Scheme, "ui", StringComparison.OrdinalIgnoreCase); + Uri.TryCreate(resourceUri, UriKind.Absolute, out var uri) + && string.Equals(uri.Scheme, "ui", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(uri.Host); } diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs index 2f2790626..10c786b1b 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Extensions.Apps; @@ -40,10 +42,14 @@ public static IMcpServerBuilder WithMcpApps(this IMcpServerBuilder builder) #if NET ArgumentNullException.ThrowIfNull(builder); #else - if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (builder is null) + throw new ArgumentNullException(nameof(builder)); #endif - builder.Services.AddSingleton, McpAppsPostConfigureOptions>(); + builder.Services.AddSingleton< + IPostConfigureOptions, + McpAppsPostConfigureOptions + >(); return builder; } @@ -54,11 +60,28 @@ public void PostConfigure(string? name, McpServerOptions options) // Advertise server-side MCP Apps support in capabilities. options.Capabilities ??= new ServerCapabilities(); options.Capabilities.Extensions ??= new Dictionary(); - if (!options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)) + if (options.Capabilities.Extensions.TryGetValue(McpApps.ExtensionId, out var existing)) + { + switch (existing) + { + case McpUiServerCapabilities typed: + typed.Elicitation ??= new McpUiElicitationCapability(); + break; + case JsonObject jsonObject: + jsonObject["elicitation"] ??= new JsonObject(); + break; + case JsonElement { ValueKind: JsonValueKind.Object } element: + var merged = JsonNode.Parse(element.GetRawText())!.AsObject(); + merged["elicitation"] ??= new JsonObject(); + options.Capabilities.Extensions[McpApps.ExtensionId] = merged; + break; + } + } + else { - options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject + options.Capabilities.Extensions[McpApps.ExtensionId] = new JsonObject { - ["elicitation"] = new System.Text.Json.Nodes.JsonObject(), + ["elicitation"] = new JsonObject(), }; } diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs index 2fe7e77e9..dfc7a0fb7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -1,9 +1,9 @@ #pragma warning disable MCPEXP003 -using ModelContextProtocol.Extensions.Apps; -using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Tests.Server; @@ -16,10 +16,7 @@ public void AddClientCapabilities_AddsCoreAndExistingUiExtensionSettings() { Extensions = new Dictionary { - [McpApps.ExtensionId] = new JsonObject - { - ["custom"] = true, - }, + [McpApps.ExtensionId] = new JsonObject { ["custom"] = true }, }, }; @@ -32,7 +29,8 @@ public void AddClientCapabilities_AddsCoreAndExistingUiExtensionSettings() Assert.Contains( McpApps.HtmlMimeType, ui["mimeTypes"]!.AsArray().Select(value => value!.GetValue()), - StringComparer.OrdinalIgnoreCase); + StringComparer.OrdinalIgnoreCase + ); } [Fact] @@ -54,15 +52,14 @@ public void SetAppUi_RoundTripsMetadataWithoutChangingCoreRequest() { var request = CreateRequest(); - var result = McpAppElicitation.SetAppUi( - request, - "ui://example/choose-option.html"); + var result = McpAppElicitation.SetAppUi(request, "ui://example/choose-option.html"); Assert.Same(request, result); Assert.Equal("Choose an option", result.Message); Assert.Equal( "ui://example/choose-option.html", - McpAppElicitation.GetAppUi(result)?.ResourceUri); + McpAppElicitation.GetAppUi(result)?.ResourceUri + ); } [Fact] @@ -70,14 +67,16 @@ public void SetAppUi_RoundTripsThroughMrtrInputRequest() { var request = McpAppElicitation.SetAppUi( CreateRequest(), - "ui://example/choose-option.html"); + "ui://example/choose-option.html" + ); var embedded = InputRequest.ForElicitation(request).ElicitationParams; Assert.NotNull(embedded); Assert.Equal( "ui://example/choose-option.html", - McpAppElicitation.GetAppUi(embedded)?.ResourceUri); + McpAppElicitation.GetAppUi(embedded)?.ResourceUri + ); } [Fact] @@ -89,7 +88,8 @@ public void SetAppUiIfSupported_FallsBackToUnmodifiedNativeRequest() request, new ClientCapabilities(), CreateServerCapabilities(), - "ui://example/choose-option.html"); + "ui://example/choose-option.html" + ); Assert.Same(request, result); Assert.Null(result.Meta); @@ -104,11 +104,13 @@ public void SetAppUiIfSupported_AttachesUiWhenNegotiated() request, CreateClientCapabilities(), CreateServerCapabilities(), - "ui://example/choose-option.html"); + "ui://example/choose-option.html" + ); Assert.Equal( "ui://example/choose-option.html", - McpAppElicitation.GetAppUi(request)?.ResourceUri); + McpAppElicitation.GetAppUi(request)?.ResourceUri + ); } [Fact] @@ -119,7 +121,8 @@ public void SetAppUiIfSupported_ClientOverload_AttachesUiWhenClientSupportsIt() McpAppElicitation.SetAppUiIfSupported( request, CreateClientCapabilities(), - "ui://example/choose-option.html"); + "ui://example/choose-option.html" + ); Assert.NotNull(McpAppElicitation.GetAppUi(request)); } @@ -127,11 +130,14 @@ public void SetAppUiIfSupported_ClientOverload_AttachesUiWhenClientSupportsIt() [Theory] [InlineData("https://example.com/view.html")] [InlineData("relative/view.html")] + [InlineData("ui:example/view.html")] + [InlineData("ui:///view.html")] [InlineData("")] public void SetAppUi_RejectsInvalidResourceUri(string resourceUri) { Assert.ThrowsAny(() => - McpAppElicitation.SetAppUi(CreateRequest(), resourceUri)); + McpAppElicitation.SetAppUi(CreateRequest(), resourceUri) + ); } [Fact] @@ -146,7 +152,8 @@ public void SetAppUi_RejectsUrlMode() }; Assert.Throws(() => - McpAppElicitation.SetAppUi(request, "ui://example/view.html")); + McpAppElicitation.SetAppUi(request, "ui://example/view.html") + ); } [Fact] @@ -159,12 +166,16 @@ public void GetAppUi_ReturnsNullForMalformedOrNonUiMetadata() var nonUi = CreateRequest(); nonUi.Meta = new JsonObject { - ["ui"] = new JsonObject - { - ["resourceUri"] = "https://example.com", - }, + ["ui"] = new JsonObject { ["resourceUri"] = "https://example.com" }, }; Assert.Null(McpAppElicitation.GetAppUi(nonUi)); + + var opaqueUi = CreateRequest(); + opaqueUi.Meta = new JsonObject + { + ["ui"] = new JsonObject { ["resourceUri"] = "ui:example/view.html" }, + }; + Assert.Null(McpAppElicitation.GetAppUi(opaqueUi)); } [Fact] @@ -202,10 +213,7 @@ public void ValidateResult_AcceptedValidContent_ReturnsNormalizedResult() { MinItems = 1, MaxItems = 2, - Items = new() - { - Enum = ["monday", "tuesday", "wednesday"], - }, + Items = new() { Enum = ["monday", "tuesday", "wednesday"] }, }, }, Required = ["email", "quantity", "expedited", "window", "days"], @@ -237,11 +245,8 @@ public void ValidateResult_MissingRequiredProperty_ReturnsError() { var validation = McpAppElicitation.ValidateResult( CreateRequest(), - new ElicitResult - { - Action = "accept", - Content = new Dictionary(), - }); + new ElicitResult { Action = "accept", Content = new Dictionary() } + ); var error = Assert.Single(validation.Errors); Assert.False(validation.IsValid); @@ -263,7 +268,8 @@ public void ValidateResult_UnexpectedProperty_ReturnsError() ["choice"] = Json("\"morning\""), ["untrusted"] = Json("\"sensitive-value\""), }, - }); + } + ); var error = Assert.Single(validation.Errors); Assert.Equal("/content/untrusted", error.Path); @@ -294,11 +300,9 @@ public void ValidateResult_InvalidIntegerValue_ReturnsError(string json) new ElicitResult { Action = "accept", - Content = new Dictionary - { - ["quantity"] = Json(json), - }, - }); + Content = new Dictionary { ["quantity"] = Json(json) }, + } + ); var error = Assert.Single(validation.Errors); Assert.Equal("/content/quantity", error.Path); @@ -321,10 +325,7 @@ public void ValidateResult_InvalidEnumChoices_ReturnErrorsWithoutValues() }, ["days"] = new ElicitRequestParams.TitledMultiSelectEnumSchema { - Items = new() - { - AnyOf = [new() { Const = "monday", Title = "Monday" }], - }, + Items = new() { AnyOf = [new() { Const = "monday", Title = "Monday" }] }, }, }, }, @@ -340,7 +341,8 @@ public void ValidateResult_InvalidEnumChoices_ReturnErrorsWithoutValues() ["window"] = Json("\"secret-window\""), ["days"] = Json("""["secret-day"]"""), }, - }); + } + ); Assert.False(validation.IsValid); Assert.Collection( @@ -354,7 +356,8 @@ public void ValidateResult_InvalidEnumChoices_ReturnErrorsWithoutValues() { Assert.Equal("/content/days/0", error.Path); Assert.DoesNotContain("secret-day", error.Message, StringComparison.Ordinal); - }); + } + ); } [Fact] @@ -367,7 +370,11 @@ public void ValidateResult_LengthNumericAndSelectionBounds_ReturnErrors() { Properties = new Dictionary { - ["name"] = new ElicitRequestParams.StringSchema { MinLength = 2, MaxLength = 4 }, + ["name"] = new ElicitRequestParams.StringSchema + { + MinLength = 2, + MaxLength = 4, + }, ["score"] = new ElicitRequestParams.NumberSchema { Minimum = 1, Maximum = 5 }, ["choices"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema { @@ -390,12 +397,14 @@ public void ValidateResult_LengthNumericAndSelectionBounds_ReturnErrors() ["score"] = Json("6"), ["choices"] = Json("""["a"]"""), }, - }); + } + ); Assert.False(validation.IsValid); Assert.Equal( ["/content/name", "/content/score", "/content/choices"], - validation.Errors.Select(error => error.Path)); + validation.Errors.Select(error => error.Path) + ); } [Theory] @@ -427,7 +436,8 @@ public void ValidateResult_InvalidStringFormat_ReturnsError(string value, string { ["value"] = JsonSerializer.SerializeToElement(value, McpApps.SerializerOptions), }, - }); + } + ); var error = Assert.Single(validation.Errors); Assert.Equal("/content/value", error.Path); @@ -466,7 +476,8 @@ public void ValidateResult_ValidStringFormat_IsAccepted(string value, string for { ["value"] = JsonSerializer.SerializeToElement(value, McpApps.SerializerOptions), }, - }); + } + ); Assert.True(validation.IsValid); } @@ -508,7 +519,8 @@ public void ValidateResult_AcceptedResult_AppliesDefaultsBeforeRequiredValidatio var validation = McpAppElicitation.ValidateResult( request, - new ElicitResult { Action = "accept" }); + new ElicitResult { Action = "accept" } + ); Assert.True(validation.IsValid); Assert.Equal("morning", validation.ValidatedResult!.Content!["window"].GetString()); @@ -524,7 +536,8 @@ public void ValidateResult_AcceptedResultWithoutContentOrDefaults_ReturnsError() Message = "Optional form", RequestedSchema = new ElicitRequestParams.RequestSchema(), }, - new ElicitResult { Action = "accept" }); + new ElicitResult { Action = "accept" } + ); var error = Assert.Single(validation.Errors); Assert.Equal("/content", error.Path); @@ -537,30 +550,31 @@ private static ClientCapabilities CreateClientCapabilities() return McpAppElicitation.AddClientCapabilities(capabilities); } - private static ServerCapabilities CreateServerCapabilities() => new() - { - Extensions = new Dictionary + private static ServerCapabilities CreateServerCapabilities() => + new() { - [McpApps.ExtensionId] = new McpUiServerCapabilities + Extensions = new Dictionary { - Elicitation = new McpUiElicitationCapability(), + [McpApps.ExtensionId] = new McpUiServerCapabilities + { + Elicitation = new McpUiElicitationCapability(), + }, }, - }, - }; + }; - private static ElicitRequestParams CreateRequest() => new() - { - Message = "Choose an option", - RequestedSchema = new ElicitRequestParams.RequestSchema + private static ElicitRequestParams CreateRequest() => + new() { - Properties = new Dictionary + Message = "Choose an option", + RequestedSchema = new ElicitRequestParams.RequestSchema { - ["choice"] = new ElicitRequestParams.StringSchema(), + Properties = new Dictionary + { + ["choice"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["choice"], }, - Required = ["choice"], - }, - }; + }; - private static JsonElement Json(string json) => - JsonDocument.Parse(json).RootElement.Clone(); + private static JsonElement Json(string json) => JsonDocument.Parse(json).RootElement.Clone(); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs index 3f004e505..f8cc87b94 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs @@ -1,13 +1,13 @@ #pragma warning disable MCPEXP003 +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using System.ComponentModel; -using System.Text.Json; -using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; @@ -55,7 +55,10 @@ public void McpUiToolMeta_CanBeRoundtrippedAsJson() }; var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); - var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + var deserialized = JsonSerializer.Deserialize( + json, + McpApps.SerializerOptions + ); Assert.NotNull(deserialized); Assert.Equal("ui://weather/view.html", deserialized.ResourceUri); @@ -87,14 +90,14 @@ public void McpUiResourceMeta_CanBeRoundtrippedAsJson() FrameDomains = ["https://embed.example.com"], BaseUris = ["https://app.example.com"], }, - Permissions = new McpUiResourcePermissions - { - Allow = ["camera", "microphone"], - }, + Permissions = new McpUiResourcePermissions { Allow = ["camera", "microphone"] }, }; var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); - var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + var deserialized = JsonSerializer.Deserialize( + json, + McpApps.SerializerOptions + ); Assert.NotNull(deserialized); Assert.Equal("https://app.example.com", deserialized.Domain); @@ -111,13 +114,13 @@ public void McpUiResourceMeta_CanBeRoundtrippedAsJson() [Fact] public void McpUiClientCapabilities_CanBeRoundtrippedAsJson() { - var caps = new McpUiClientCapabilities - { - MimeTypes = [McpApps.HtmlMimeType], - }; + var caps = new McpUiClientCapabilities { MimeTypes = [McpApps.HtmlMimeType] }; var json = JsonSerializer.Serialize(caps, McpApps.SerializerOptions); - var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); + var deserialized = JsonSerializer.Deserialize( + json, + McpApps.SerializerOptions + ); Assert.NotNull(deserialized); Assert.Equal([McpApps.HtmlMimeType], deserialized.MimeTypes); @@ -146,10 +149,7 @@ public void GetUiCapability_ReturnsNull_WhenExtensionKeyIsMissing() #pragma warning disable MCPEXP001 var caps = new ClientCapabilities { - Extensions = new Dictionary - { - ["other.extension"] = new { }, - } + Extensions = new Dictionary { ["other.extension"] = new { } }, }; #pragma warning restore MCPEXP001 Assert.Null(McpApps.GetUiCapability(caps)); @@ -170,7 +170,10 @@ public void GetUiCapability_ReturnsCapabilities_WhenExtensionIsPresent() } """; - var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var caps = JsonSerializer.Deserialize( + json, + McpJsonUtilities.DefaultOptions + ); Assert.NotNull(caps); var uiCaps = McpApps.GetUiCapability(caps); @@ -190,7 +193,10 @@ public void GetUiCapability_ReturnsNull_WhenExtensionValueIsNull() } """; - var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var caps = JsonSerializer.Deserialize( + json, + McpJsonUtilities.DefaultOptions + ); Assert.NotNull(caps); Assert.Null(McpApps.GetUiCapability(caps)); @@ -211,7 +217,10 @@ public void GetUiCapability_ReturnsNull_WhenExtensionValueIsMalformed(string mal } """; - var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var caps = JsonSerializer.Deserialize( + json, + McpJsonUtilities.DefaultOptions + ); Assert.NotNull(caps); // Should return null gracefully, not throw @@ -230,7 +239,7 @@ public void GetUiCapability_ReturnsCapabilities_WhenValueIsStronglyTyped() { MimeTypes = [McpApps.HtmlMimeType], }, - } + }, }; #pragma warning restore MCPEXP001 @@ -263,7 +272,9 @@ public void ApplyAppUiAttributes_PopulatesUiObject() [Fact] public void ApplyAppUiAttributes_WithVisibility_IncludesVisibilityInUiObject() { - var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!; + var method = typeof(TestToolsWithAppUi).GetMethod( + nameof(TestToolsWithAppUi.ModelOnlyTool) + )!; var tool = McpServerTool.Create(method, target: null); McpApps.ApplyAppUiAttributes(tool); @@ -288,7 +299,11 @@ public void ApplyAppUiAttributes_ExplicitMeta_TakesPrecedence() ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/override.html" }, }; - var tool = McpServerTool.Create(method, target: null, new McpServerToolCreateOptions { Meta = explicitMeta }); + var tool = McpServerTool.Create( + method, + target: null, + new McpServerToolCreateOptions { Meta = explicitMeta } + ); McpApps.ApplyAppUiAttributes(tool); @@ -302,8 +317,14 @@ public void ApplyAppUiAttributes_Collection_ProcessesAllTools() { var tools = new[] { - McpServerTool.Create(typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!, target: null), - McpServerTool.Create(typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!, target: null), + McpServerTool.Create( + typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!, + target: null + ), + McpServerTool.Create( + typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!, + target: null + ), }; McpApps.ApplyAppUiAttributes(tools); @@ -317,7 +338,8 @@ public void ApplyAppUiAttributes_NoAttribute_DoesNothing() { var tool = McpServerTool.Create( (string input) => input, - new McpServerToolCreateOptions { Name = "plain_tool" }); + new McpServerToolCreateOptions { Name = "plain_tool" } + ); McpApps.ApplyAppUiAttributes(tool); @@ -333,7 +355,8 @@ public void SetAppUi_PopulatesUiObject() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", - new McpServerToolCreateOptions { Name = "get_weather" }); + new McpServerToolCreateOptions { Name = "get_weather" } + ); McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html" }); @@ -350,13 +373,17 @@ public void SetAppUi_WithVisibility_IncludesVisibilityInUiObject() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", - new McpServerToolCreateOptions { Name = "get_weather" }); + new McpServerToolCreateOptions { Name = "get_weather" } + ); - McpApps.SetAppUi(tool, new McpUiToolMeta - { - ResourceUri = "ui://weather/view.html", - Visibility = [McpUiToolVisibility.Model], - }); + McpApps.SetAppUi( + tool, + new McpUiToolMeta + { + ResourceUri = "ui://weather/view.html", + Visibility = [McpUiToolVisibility.Model], + } + ); var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); Assert.NotNull(uiNode); @@ -379,7 +406,8 @@ public void SetAppUi_DoesNotOverwrite_ExistingUiKey() { ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/view.html" }, }, - }); + } + ); McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://new/view.html" }); @@ -393,7 +421,8 @@ public void SetAppUi_NullResourceUri_ProducesUiObjectWithoutResourceUri() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", - new McpServerToolCreateOptions { Name = "get_weather" }); + new McpServerToolCreateOptions { Name = "get_weather" } + ); McpApps.SetAppUi(tool, new McpUiToolMeta { Visibility = [McpUiToolVisibility.App] }); @@ -407,9 +436,13 @@ public void SetAppUi_ReturnsSameTool() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", - new McpServerToolCreateOptions { Name = "get_weather" }); + new McpServerToolCreateOptions { Name = "get_weather" } + ); - var result = McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html" }); + var result = McpApps.SetAppUi( + tool, + new McpUiToolMeta { ResourceUri = "ui://weather/view.html" } + ); Assert.Same(tool, result); } @@ -421,9 +454,7 @@ public void SetAppUi_ReturnsSameTool() public void WithMcpApps_AppliesAppUiAttributes_ViaOptions() { var sc = new ServiceCollection(); - sc.AddMcpServer() - .WithTools([typeof(TestToolsWithAppUi)]) - .WithMcpApps(); + sc.AddMcpServer().WithTools([typeof(TestToolsWithAppUi)]).WithMcpApps(); using var sp = sc.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; @@ -432,10 +463,15 @@ public void WithMcpApps_AppliesAppUiAttributes_ViaOptions() Assert.NotEmpty(options.ToolCollection); // Both tools should have their [McpAppUi] attributes applied - var toolsWithUi = options.ToolCollection.Where(t => t.ProtocolTool.Meta?["ui"] is not null).ToList(); + var toolsWithUi = options + .ToolCollection.Where(t => t.ProtocolTool.Meta?["ui"] is not null) + .ToList(); Assert.Equal(2, toolsWithUi.Count); - var weatherTool = toolsWithUi.First(t => t.ProtocolTool.Meta!["ui"]!["resourceUri"]?.GetValue() == "ui://weather/view.html"); + var weatherTool = toolsWithUi.First(t => + t.ProtocolTool.Meta!["ui"]!["resourceUri"]?.GetValue() + == "ui://weather/view.html" + ); Assert.NotNull(weatherTool); } @@ -443,8 +479,7 @@ public void WithMcpApps_AppliesAppUiAttributes_ViaOptions() public void WithMcpApps_EmptyToolCollection_DoesNotThrow() { var sc = new ServiceCollection(); - sc.AddMcpServer() - .WithMcpApps(); + sc.AddMcpServer().WithMcpApps(); using var sp = sc.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; @@ -457,8 +492,7 @@ public void WithMcpApps_EmptyToolCollection_DoesNotThrow() public void WithMcpApps_AdvertisesServerCapability() { var sc = new ServiceCollection(); - sc.AddMcpServer() - .WithMcpApps(); + sc.AddMcpServer().WithMcpApps(); using var sp = sc.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; @@ -467,14 +501,84 @@ public void WithMcpApps_AdvertisesServerCapability() Assert.NotNull(options.Capabilities.Extensions); Assert.True(options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)); var uiCapabilities = Assert.IsType( - options.Capabilities.Extensions[McpApps.ExtensionId]); + options.Capabilities.Extensions[McpApps.ExtensionId] + ); Assert.NotNull(uiCapabilities["elicitation"]); } + [Fact] + public void WithMcpApps_MergesTypedServerCapability() + { + var existing = new McpUiServerCapabilities(); + var options = GetMcpAppsOptions(existing); + + Assert.Same(existing, options.Capabilities!.Extensions![McpApps.ExtensionId]); + Assert.NotNull(existing.Elicitation); + } + + [Fact] + public void WithMcpApps_MergesJsonObjectServerCapabilityAndPreservesSettings() + { + var existingElicitation = new JsonObject { ["custom"] = true }; + var existing = new JsonObject + { + ["customSetting"] = "preserved", + ["elicitation"] = existingElicitation, + }; + + var options = GetMcpAppsOptions(existing); + + var merged = Assert.IsType( + options.Capabilities!.Extensions![McpApps.ExtensionId] + ); + Assert.Same(existing, merged); + Assert.Equal("preserved", merged["customSetting"]!.GetValue()); + Assert.Same(existingElicitation, merged["elicitation"]); + } + + [Fact] + public void WithMcpApps_MergesJsonElementServerCapabilityAndIsIdempotent() + { + var existing = JsonDocument + .Parse("""{"customSetting":"preserved","nested":{"value":42}}""") + .RootElement.Clone(); + + var options = GetMcpAppsOptions(existing, callTwice: true); + + var merged = Assert.IsType( + options.Capabilities!.Extensions![McpApps.ExtensionId] + ); + Assert.Equal("preserved", merged["customSetting"]!.GetValue()); + Assert.Equal(42, merged["nested"]!["value"]!.GetValue()); + Assert.IsType(merged["elicitation"]); + Assert.Single(merged, property => property.Key == "elicitation"); + } + #endregion #region Test helper types + private static McpServerOptions GetMcpAppsOptions(object existing, bool callTwice = false) + { + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + services.Configure(options => + { + options.Capabilities = new ServerCapabilities + { + Extensions = new Dictionary { [McpApps.ExtensionId] = existing }, + }; + }); + builder.WithMcpApps(); + if (callTwice) + { + builder.WithMcpApps(); + } + + using var serviceProvider = services.BuildServiceProvider(); + return serviceProvider.GetRequiredService>().Value; + } + [McpServerToolType] private static class TestToolsWithAppUi { @@ -484,7 +588,10 @@ private static class TestToolsWithAppUi public static string WeatherTool(string location) => $"Weather for {location}"; [McpServerTool] - [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])] + [McpAppUi( + ResourceUri = "ui://model-only/view.html", + Visibility = [McpUiToolVisibility.Model] + )] public static string ModelOnlyTool(string location) => $"Model only for {location}"; }