diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index f22479966..7c61ff13f 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -158,6 +158,102 @@ public static string GetWeather(McpServer server, string location) } ``` +## App-rendered elicitations + +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. + +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. 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 +{ + 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"); + +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 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 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/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..cbb94851d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.Validation.cs @@ -0,0 +1,533 @@ +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[4] != '-' || + value[7] != '-' || + value[10] is not ('T' or 't') || + value[13] != ':' || + value[16] != ':') + { + return false; + } + + for (int i = 0; i < 19; i++) + { + if (i is not (4 or 7 or 10 or 13 or 16) && !IsAsciiDigit(value[i])) + { + return false; + } + } + + string dateAndTime = value.Substring(0, 10) + 'T' + value.Substring(11, 8); + if (!DateTime.TryParseExact( + dateAndTime, + "yyyy-MM-dd'T'HH:mm:ss", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _)) + { + return false; + } + + int timezoneIndex = 19; + if (value[timezoneIndex] == '.') + { + timezoneIndex++; + int fractionStart = timezoneIndex; + while (timezoneIndex < value.Length && IsAsciiDigit(value[timezoneIndex])) + { + timezoneIndex++; + } + + if (timezoneIndex == fractionStart) + { + return false; + } + } + + if (timezoneIndex == value.Length - 1 && value[timezoneIndex] is 'Z' or 'z') + { + return true; + } + + if (value.Length - timezoneIndex != 6 || + value[timezoneIndex] is not ('+' or '-') || + value[timezoneIndex + 3] != ':' || + !IsAsciiDigit(value[timezoneIndex + 1]) || + !IsAsciiDigit(value[timezoneIndex + 2]) || + !IsAsciiDigit(value[timezoneIndex + 4]) || + !IsAsciiDigit(value[timezoneIndex + 5])) + { + return false; + } + + int offsetHours = + (value[timezoneIndex + 1] - '0') * 10 + value[timezoneIndex + 2] - '0'; + int offsetMinutes = + (value[timezoneIndex + 4] - '0') * 10 + value[timezoneIndex + 5] - '0'; + return offsetHours <= 14 && + offsetMinutes <= 59 && + (offsetHours != 14 || offsetMinutes == 0); + } + + private static bool IsAsciiDigit(char value) => value is >= '0' and <= '9'; + + 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 new file mode 100644 index 000000000..ca4e4724d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -0,0 +1,228 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Extensions.Apps; + +/// Provides conventions for using MCP Apps as form elicitation UI. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public static partial 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 ??= []; + var ui = request.Meta["ui"] as JsonObject ?? []; + ui["resourceUri"] = resourceUri; + request.Meta["ui"] = ui; + 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. 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, + 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) + && !string.IsNullOrEmpty(uri.Host); +} 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..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, . @@ -111,6 +116,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..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,9 +60,29 @@ 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)) { - options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject(); + 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 JsonObject + { + ["elicitation"] = new 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..c258ad483 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs @@ -14,6 +14,13 @@ namespace ModelContextProtocol.Extensions.Apps; [JsonSerializable(typeof(McpUiResourceMeta))] [JsonSerializable(typeof(McpUiResourceCsp))] [JsonSerializable(typeof(McpUiResourcePermissions))] +[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/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..371d29b8d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -0,0 +1,624 @@ +#pragma warning disable MCPEXP003 + +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; + +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 SetAppUi_PreservesExistingUiFieldsAndUpdatesResourceUri() + { + var request = CreateRequest(); + request.Meta = new JsonObject + { + ["ui"] = new JsonObject + { + ["resourceUri"] = "ui://example/old.html", + ["custom"] = new JsonObject { ["enabled"] = true }, + }, + }; + + McpAppElicitation.SetAppUi(request, "ui://example/new.html"); + + var ui = Assert.IsType(request.Meta["ui"]); + Assert.Equal("ui://example/new.html", ui["resourceUri"]?.GetValue()); + Assert.True(ui["custom"]?["enabled"]?.GetValue()); + } + + [Fact] + public void SetAppUi_ReplacesMalformedUiMetadata() + { + var request = CreateRequest(); + request.Meta = new JsonObject { ["ui"] = "not-an-object" }; + + McpAppElicitation.SetAppUi(request, "ui://example/view.html"); + + var ui = Assert.IsType(request.Meta["ui"]); + Assert.Equal("ui://example/view.html", ui["resourceUri"]?.GetValue()); + } + + [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() + { + 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("ui:example/view.html")] + [InlineData("ui:///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)); + + var opaqueUi = CreateRequest(); + opaqueUi.Meta = new JsonObject + { + ["ui"] = new JsonObject { ["resourceUri"] = "ui:example/view.html" }, + }; + Assert.Null(McpAppElicitation.GetAppUi(opaqueUi)); + } + + [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")] + [InlineData("2026-02-30T11:22:30Z", "date-time")] + [InlineData("2026-08-03T24:00:00Z", "date-time")] + [InlineData("2026-08-03T11:22:30", "date-time")] + [InlineData("2026-08-03T11:22:30.Z", "date-time")] + [InlineData("2026-08-03T11:22:30.123456789", "date-time")] + [InlineData("2026-08-03T11:22:30+0700", "date-time")] + [InlineData("2026-08-03T11:22:30+07:60", "date-time")] + [InlineData("2026-08-03T11:22:30+14:01", "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:30z", "date-time")] + [InlineData("2026-08-03T11:22:30+05:30", "date-time")] + [InlineData("2026-08-03T11:22:30.462-07:00", "date-time")] + [InlineData("2026-08-03T11:22:30.123456789Z", "date-time")] + [InlineData("2026-08-03T11:22:30.123456789+14: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(); + 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"], + }, + }; + + 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 756417de0..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; @@ -466,12 +500,85 @@ 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"]); + } + + [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 { @@ -481,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}"; }