diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 9020d2fbe..d2810ff3f 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -40,6 +40,8 @@ + + diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index 285f48de5..fe12b0242 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -117,6 +117,43 @@ The `Visibility` property controls which principals can invoke the tool: | `McpUiToolVisibility.App` | Only the app UI can call this tool | | Both (or null/empty) | Both the model and app can call the tool (default) | +## App-rendered elicitations + +The experimental `McpAppElicitation` conventions let a server associate a core form elicitation with an MCP App. +The normal `requestedSchema` remains authoritative and provides the native-form fallback. The app resource hint is +only attached when the requesting client advertises core form elicitation and the `elicitation` member of the +`io.modelcontextprotocol/ui` capability: + +```csharp +var elicitation = McpAppElicitation.SetAppUiIfSupported( + new ElicitRequestParams + { + Message = "Review and confirm the account manager.", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["confirmed"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["confirmed"], + }, + }, + context, + "ui://portfolio/assign-manager"); + +var response = McpAppElicitation.ResolveOrRequest( + server, + context.Params, + "manager-assignment", + elicitation, + MyJsonContext.Default.ManagerAssignment, + requestState: "assign-account-manager:v1"); +``` + +On protocol revision `2026-07-28`, `ResolveOrRequest` uses stateless Multi Round-Trip Requests (MRTR). On compatible +legacy stateful connections, the SDK resolves the same input request through the standard `elicitation/create` +request. See [the prototype design](../../extensions/apps-elicitation.md) for the wire contract and fallback rules. + ## UI resources UI resources are HTML pages registered with the MCP server using the `ui://` URI scheme and the `text/html;profile=mcp-app` MIME type. The `McpUiResourceMeta` type provides metadata for these resources, including: diff --git a/docs/extensions/apps-elicitation.md b/docs/extensions/apps-elicitation.md new file mode 100644 index 000000000..f8f6dccc5 --- /dev/null +++ b/docs/extensions/apps-elicitation.md @@ -0,0 +1,113 @@ +# MCP Apps as elicitation UI: prototype extension + +This prototype composes core form elicitation, MCP Apps, and Multi Round-Trip Requests (MRTR) into one +interoperable flow. It is informed by ext-apps issue #511, discussion #514, PR #531, and the deferred-tool +workaround in PR #390. + +## Capability negotiation + +An MCP Apps host does not necessarily know how to route an elicitation to an app, manage its input-required +lifecycle, or fall back safely. This prototype adds an optional `elicitation` member to the existing MCP Apps +client capability: + +```json +{ + "capabilities": { + "elicitation": { "form": {} }, + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"], + "elicitation": {} + } + } + } +} +``` + +The `elicitation` member is experimental and does not claim adoption by the MCP project. Keeping it inside +`io.modelcontextprotocol/ui` avoids inventing extension dependency semantics and lets MCP Apps evolve additively. + +## Elicitation request convention + +The request remains a valid core form elicitation. The app link reuses MCP Apps metadata exactly as proposed in +issue #511: + +```json +{ + "method": "elicitation/create", + "params": { + "mode": "form", + "message": "Review the portfolio and confirm its manager.", + "requestedSchema": { + "type": "object", + "properties": { + "confirmed": { "type": "boolean" }, + "selectedManagerId": { "type": "string" } + }, + "required": ["confirmed", "selectedManagerId"] + }, + "_meta": { + "ui": { "resourceUri": "ui://portfolio/assign-manager" } + } + } +} +``` + +A host supporting MCP Apps elicitation reads and renders the resource, then forwards `elicitation/create` to that app +as JSON-RPC after the normal `ui/initialize` / `ui/notifications/initialized` handshake. The app returns the +standard `ElicitResult`. This follows the direction explored by PR #531 while making app selection explicit. + +A capability-aware server omits `_meta.ui` when the client has form elicitation but lacks MCP Apps elicitation, so +the client renders `requestedSchema` using its native form UI. A server that sends the optional hint unconditionally +remains compatible with clients that ignore unknown metadata. In both cases, the server receives the same core +`ElicitResult`. + +## Stateless 2026-07-28 MRTR flow + +```text +Host Stateless MCP server MCP App + | tools/call -----------------> | | + | <--- input_required ----------| | + | elicitation/create + ui:// resource | + | resources/read -------------> | | + | <--- text/html;profile=mcp-app| | + | ui/initialize ----------------------------------------> | + | <----------------------------- ui/notifications/initialized + | elicitation/create ----------------------------------> | + | <----------------------------- ElicitResult -----------| + | tools/call + inputResponses ->| | + | <--- final CallToolResult -----| | +``` + +The server cannot suspend an in-memory handler across stateless HTTP requests. The C# convention therefore uses +`InputRequiredException` on round one and deterministically re-runs the handler on round two. The original tool +arguments and opaque `requestState` must contain everything needed to resume safely. Implementations must avoid +performing non-idempotent work before the elicitation has resolved. + +## C# API shape + +- `WithMcpApps()` advertises the MCP Apps extension. +- `AddClientCapabilities(...)` advertises form elicitation plus the MCP Apps elicitation capability. +- `SetAppUi(...)` and `GetAppUi(...)` strongly type the `_meta.ui.resourceUri` convention. +- `SetAppUiIfSupported(...)` reads the request-scoped 2026-07-28 capabilities (or legacy session capabilities) and + leaves the core request unchanged unless form elicitation and both app extensions were advertised. +- `ResolveOrRequest(...)` emits the first-round MRTR request and deserializes the retried response as `T`. + +## Host requirements and safety + +- Validate the URI and only resolve declared `ui://` resources from the requesting server. +- Preserve the normal elicitation identity, review, decline, cancel, and notification behavior. +- Validate accepted content against `requestedSchema`; the app is not a trusted validator. +- Apply the complete MCP Apps sandbox, CSP, permissions, origin, and teardown rules. +- Do not use form mode for secrets or credentials; use core URL-mode elicitation for sensitive input. +- Bind pending elicitations to the originating server, user, request, and rendered app instance. +- Support sequential requests explicitly; concurrent routing needs stable per-elicitation app instances. + +## Remaining spec questions + +1. Should forwarding use the standard `elicitation/create` method, as PR #531 does, or a UI-prefixed method? +2. Should app support be declared in `ui/initialize` as the same first-class `elicitation` capability? +3. Should `_meta.ui.resourceUri` alone opt into routing, or must the MCP Apps elicitation capability be present? +4. Who performs final schema validation and how are invalid app responses surfaced without losing the elicitation? +5. What lifecycle notification tells the app and host that the elicitation has completed or been cancelled externally? +6. How should multiple simultaneous app elicitations from one tool call be ordered and displayed? diff --git a/samples/AppElicitation/README.md b/samples/AppElicitation/README.md new file mode 100644 index 000000000..d17183483 --- /dev/null +++ b/samples/AppElicitation/README.md @@ -0,0 +1,33 @@ +# MCP App as custom elicitation UI + +This sample is a minimal host + server implementation of the composition proposed in +[ext-apps#511](https://github.com/modelcontextprotocol/ext-apps/issues/511). + +See [the prototype extension design](../../docs/extensions/apps-elicitation.md) for the proposed wire contract, +fallback behavior, safety requirements, and open specification questions. + +The server is deliberately stateless and pins the draft `2026-07-28` protocol. The flow is: + +1. The host calls `assign_account_manager`. +2. The server throws `InputRequiredException` with an `elicitation/create` input request. +3. The request retains the normal `requestedSchema` and adds `_meta.ui.resourceUri` only when the requesting client + advertised form elicitation and the MCP Apps `elicitation` capability. +4. The host reads the `ui://` MCP App resource and performs the Apps `ui/initialize` handshake. +5. The host forwards `elicitation/create` to the iframe as JSON-RPC. +6. The app returns `ElicitResult`; the C# client places it in `inputResponses` and retries the tool. +7. The stateless tool deserializes the response as `ManagerAssignment` and completes. + +A client that advertises only core form elicitation receives the same `requestedSchema` without `_meta.ui`, renders +its native form, and completes the identical MRTR retry. + +Run in two terminals: + +```bash +dotnet run --project samples/AppElicitationServer +dotnet run --project samples/AppElicitationHost +``` + +Then open and choose **Run assign_account_manager**. + +The browser host is intentionally small. It demonstrates the proposed lifecycle and wire shape, but it is not a +general-purpose MCP Apps host or a substitute for the ext-apps sandbox proxy implementation. diff --git a/samples/AppElicitationHost/AppElicitationHost.csproj b/samples/AppElicitationHost/AppElicitationHost.csproj new file mode 100644 index 000000000..073467368 --- /dev/null +++ b/samples/AppElicitationHost/AppElicitationHost.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + $(NoWarn);MCPEXP003 + + + + + + + + + + + diff --git a/samples/AppElicitationHost/PendingElicitationStore.cs b/samples/AppElicitationHost/PendingElicitationStore.cs new file mode 100644 index 000000000..227904b38 --- /dev/null +++ b/samples/AppElicitationHost/PendingElicitationStore.cs @@ -0,0 +1,82 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +public sealed class PendingElicitationStore +{ + private readonly object _gate = new(); + private PendingElicitation? _pending; + + public Task PublishAsync( + ElicitRequestParams request, + string resourceUri, + string html, + CancellationToken cancellationToken) + { + var pending = new PendingElicitation(Guid.NewGuid().ToString("N"), request, resourceUri, html); + lock (_gate) + { + if (_pending is not null) + { + throw new InvalidOperationException("This minimal host supports one active elicitation at a time."); + } + _pending = pending; + } + + cancellationToken.Register(() => pending.Completion.TrySetCanceled(cancellationToken)); + return AwaitAndClearAsync(pending); + } + + public PendingElicitation? GetPending() + { + lock (_gate) + { + return _pending; + } + } + + public bool Complete(string id, ElicitResult result) + { + lock (_gate) + { + return _pending?.Id == id && _pending.Completion.TrySetResult(result); + } + } + + private async Task AwaitAndClearAsync(PendingElicitation pending) + { + try + { + return await pending.Completion.Task.ConfigureAwait(false); + } + finally + { + lock (_gate) + { + if (ReferenceEquals(_pending, pending)) + { + _pending = null; + } + } + } + } +} + +public sealed class PendingElicitation( + string id, + ElicitRequestParams request, + string resourceUri, + string html) +{ + public string Id { get; } = id; + public ElicitRequestParams Request { get; } = request; + public string ResourceUri { get; } = resourceUri; + public string Html { get; } = html; + public TaskCompletionSource Completion { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); +} + +public sealed class SubmitElicitation +{ + public string Action { get; set; } = "cancel"; + public IDictionary? Content { get; set; } +} diff --git a/samples/AppElicitationHost/Program.cs b/samples/AppElicitationHost/Program.cs new file mode 100644 index 000000000..e506681eb --- /dev/null +++ b/samples/AppElicitationHost/Program.cs @@ -0,0 +1,76 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; + +var builder = WebApplication.CreateBuilder(args); +var pendingStore = new PendingElicitationStore(); + +McpClient? mcpClient = null; +var capabilities = McpAppElicitation.AddClientCapabilities(new ClientCapabilities()); +var clientOptions = new McpClientOptions +{ + ProtocolVersion = "2026-07-28", + ClientInfo = new Implementation { Name = "minimal-app-elicitation-host", Version = "0.1.0" }, + Capabilities = capabilities, +}; + +clientOptions.Handlers.ElicitationHandler = async (request, cancellationToken) => +{ + if (request is null || McpAppElicitation.GetAppUi(request) is not { } appUi) + { + return new ElicitResult { Action = "decline" }; + } + + var resource = await mcpClient!.ReadResourceAsync(appUi.ResourceUri, cancellationToken: cancellationToken); + var html = resource.Contents.OfType().Single().Text; + return await pendingStore.PublishAsync(request, appUi.ResourceUri, html, cancellationToken); +}; + +var transport = new HttpClientTransport(new HttpClientTransportOptions +{ + Endpoint = new Uri("http://localhost:5100/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, +}); +mcpClient = await McpClient.CreateAsync(transport, clientOptions); + +var app = builder.Build(); + +app.MapGet("/", () => Results.File( + Path.Combine(AppContext.BaseDirectory, "wwwroot", "index.html"), + "text/html")); + +app.MapPost("/api/run", async (CancellationToken cancellationToken) => +{ + var result = await mcpClient.CallToolAsync( + "assign_account_manager", + cancellationToken: cancellationToken); + var text = result.Content.OfType().FirstOrDefault()?.Text ?? "No text result."; + return Results.Ok(new { text, result.StructuredContent }); +}); + +app.MapGet("/api/elicitation", () => +{ + var pending = pendingStore.GetPending(); + return pending is null + ? Results.NoContent() + : Results.Ok(new + { + pending.Id, + pending.ResourceUri, + request = pending.Request, + pending.Html, + }); +}); + +app.MapPost("/api/elicitation/{id}", (string id, SubmitElicitation submission) => +{ + var completed = pendingStore.Complete(id, new ElicitResult + { + Action = submission.Action, + Content = submission.Content, + }); + return completed ? Results.Accepted() : Results.NotFound(); +}); + +app.Lifetime.ApplicationStopping.Register(() => mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult()); +app.Run("http://localhost:5200"); diff --git a/samples/AppElicitationHost/wwwroot/index.html b/samples/AppElicitationHost/wwwroot/index.html new file mode 100644 index 000000000..ea1e20742 --- /dev/null +++ b/samples/AppElicitationHost/wwwroot/index.html @@ -0,0 +1,95 @@ + + + + + + MCP App elicitation host + + + +

Custom MCP App elicitation

+

The tool runs on a stateless 2026-07-28 MCP server. Its first response is input_required; this host loads the attached ui:// resource and retries after the app resolves the elicitation.

+ +
Ready.
+
+ + + diff --git a/samples/AppElicitationServer/AppElicitationServer.csproj b/samples/AppElicitationServer/AppElicitationServer.csproj new file mode 100644 index 000000000..3c848ea82 --- /dev/null +++ b/samples/AppElicitationServer/AppElicitationServer.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + $(NoWarn);MCPEXP003 + + + + + + + + + + + + diff --git a/samples/AppElicitationServer/PortfolioResources.cs b/samples/AppElicitationServer/PortfolioResources.cs new file mode 100644 index 000000000..714fbbcdd --- /dev/null +++ b/samples/AppElicitationServer/PortfolioResources.cs @@ -0,0 +1,18 @@ +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Server; +using System.ComponentModel; + +[McpServerResourceType] +public sealed class PortfolioResources +{ + private static readonly string UiDirectory = Path.Combine(AppContext.BaseDirectory, "ui"); + + [McpServerResource( + UriTemplate = "ui://portfolio/assign-manager", + Name = "portfolio-assign-manager", + MimeType = McpApps.HtmlMimeType)] + [McpMeta("ui", """{"prefersBorder":true}""")] + [Description("Custom portfolio review and account manager assignment elicitation UI.")] + public static string GetAssignManagerUi() => + File.ReadAllText(Path.Combine(UiDirectory, "assign-manager.html")); +} diff --git a/samples/AppElicitationServer/PortfolioTools.cs b/samples/AppElicitationServer/PortfolioTools.cs new file mode 100644 index 000000000..cffdd3b60 --- /dev/null +++ b/samples/AppElicitationServer/PortfolioTools.cs @@ -0,0 +1,89 @@ +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +[McpServerToolType] +public sealed class PortfolioTools +{ + [McpServerTool(Name = "assign_account_manager")] + [Description("Review a customer portfolio and ask the user to confirm an account manager assignment.")] + public static CallToolResult AssignAccountManager( + McpServer server, + RequestContext context) + { + var elicitation = McpAppElicitation.SetAppUiIfSupported( + new ElicitRequestParams + { + Message = "Review the Contoso portfolio and confirm its account manager.", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["confirmed"] = new ElicitRequestParams.BooleanSchema + { + Title = "Confirm assignment", + Default = true, + }, + ["selectedManagerId"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema + { + Title = "Account manager", + Enum = ["mgr-alex", "mgr-priya", "mgr-sam"], + Default = "mgr-priya", + }, + }, + Required = ["confirmed", "selectedManagerId"], + }, + }, + context, + "ui://portfolio/assign-manager"); + + var response = McpAppElicitation.ResolveOrRequest( + server, + context.Params, + inputKey: "manager-assignment", + elicitation, + DemoJsonContext.Default.ManagerAssignment, + requestState: "assign-account-manager:v1"); + + if (!response.IsAccepted || response.Content is null) + { + var disposition = response.Action switch + { + "decline" => "declined", + "cancel" => "canceled", + _ => response.Action, + }; + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"The user {disposition} the manager assignment." }], + }; + } + + var assignment = response.Content; + var summary = assignment.Confirmed + ? $"Assigned Contoso to {assignment.SelectedManagerId}." + : $"The user selected {assignment.SelectedManagerId} but did not confirm the assignment."; + + return new CallToolResult + { + Content = [new TextContentBlock { Text = summary }], + StructuredContent = JsonSerializer.SerializeToElement(assignment, DemoJsonContext.Default.ManagerAssignment), + }; + } +} + +public sealed class ManagerAssignment +{ + public bool Confirmed { get; set; } + + public string SelectedManagerId { get; set; } = string.Empty; +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(ManagerAssignment))] +internal sealed partial class DemoJsonContext : JsonSerializerContext +{ +} diff --git a/samples/AppElicitationServer/Program.cs b/samples/AppElicitationServer/Program.cs new file mode 100644 index 000000000..0fc910010 --- /dev/null +++ b/samples/AppElicitationServer/Program.cs @@ -0,0 +1,27 @@ +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; + +Console.WriteLine("Configuring the stateless MCP app-elicitation server..."); +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddMcpServer(options => + { + options.ProtocolVersion = "2026-07-28"; + options.ServerInfo = new Implementation { Name = "app-elicitation-server", Version = "0.1.0" }; + options.Capabilities = new ServerCapabilities + { + Tools = new ToolsCapability(), + Resources = new ResourcesCapability(), + }; + }) + .WithHttpTransport(options => options.Stateless = true) + .WithTools() + .WithResources() + .WithMcpApps(); + +var app = builder.Build(); +app.MapMcp("/mcp"); +Console.WriteLine("Listening on http://localhost:5100/mcp"); +app.Run("http://localhost:5100"); diff --git a/samples/AppElicitationServer/ui/assign-manager.html b/samples/AppElicitationServer/ui/assign-manager.html new file mode 100644 index 000000000..8face735d --- /dev/null +++ b/samples/AppElicitationServer/ui/assign-manager.html @@ -0,0 +1,83 @@ + + + + + + + + +
Portfolio review
+

Contoso

+
Healthy · renewal in 82 days
+
+
$1.2MARR
+
94%adoption
+
3open risks
+
+

Choose the manager who should own this portfolio.

+ + + +
+ + +
+ + + diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs new file mode 100644 index 000000000..7a20649e3 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -0,0 +1,233 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Extensions.Apps; + +/// Strongly typed conventions for using MCP Apps as form elicitation UI. +[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] +public static class McpAppElicitation +{ + /// The metadata member inherited from the MCP Apps extension. + public const string UiMetaKey = "ui"; + + /// Adds all 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(); + + if (!capabilities.Extensions.TryGetValue(McpApps.ExtensionId, out var value)) + { + capabilities.Extensions[McpApps.ExtensionId] = new JsonObject + { + ["mimeTypes"] = new JsonArray(McpApps.HtmlMimeType), + ["elicitation"] = new JsonObject(), + }; + } + else + { + var appCapabilities = value switch + { + McpUiClientCapabilities typed => JsonSerializer.SerializeToNode( + typed, + McpAppsJsonContext.Default.McpUiClientCapabilities)!.AsObject(), + JsonObject jsonObject => jsonObject, + JsonElement { ValueKind: JsonValueKind.Object } element => JsonNode.Parse(element.GetRawText())!.AsObject(), + _ => new JsonObject(), + }; + if (appCapabilities["mimeTypes"] is not JsonArray mimeTypes) + { + mimeTypes = new JsonArray(); + appCapabilities["mimeTypes"] = mimeTypes; + } + + if (!mimeTypes.Any(node => + node is JsonValue valueNode && + valueNode.TryGetValue(out var mimeType) && + string.Equals(mimeType, McpApps.HtmlMimeType, StringComparison.OrdinalIgnoreCase))) + { + mimeTypes.Add((JsonNode?)JsonValue.Create(McpApps.HtmlMimeType)); + } + + if (appCapabilities["elicitation"] is not JsonObject) + { + appCapabilities["elicitation"] = new JsonObject(); + } + + capabilities.Extensions[McpApps.ExtensionId] = appCapabilities; + } + + return capabilities; + } + + /// Returns whether the client advertised form elicitation and MCP Apps elicitation support. + public static bool IsSupported(ClientCapabilities? capabilities) + { + var appCapabilities = McpApps.GetUiCapability(capabilities); + return capabilities?.Elicitation?.Form is not null && + appCapabilities?.Elicitation is not null && + appCapabilities.MimeTypes?.Contains(McpApps.HtmlMimeType, StringComparer.OrdinalIgnoreCase) == true; + } + + /// Associates an elicitation request with an MCP App UI resource. + public static ElicitRequestParams SetAppUi(ElicitRequestParams request, string resourceUri) + { + ValidateAppUiArguments(request, resourceUri); + + request.Meta ??= []; + request.Meta[UiMetaKey] = JsonSerializer.SerializeToNode( + new McpAppElicitationMeta { ResourceUri = resourceUri }, + McpAppsJsonContext.Default.McpAppElicitationMeta); + return request; + } + + /// + /// Associates an elicitation request with an MCP App UI resource when the client advertised all required + /// capabilities. Otherwise, leaves the core elicitation unchanged for native form rendering. + /// + public static ElicitRequestParams SetAppUiIfSupported( + ElicitRequestParams request, + ClientCapabilities? capabilities, + string resourceUri) + { + ValidateAppUiArguments(request, resourceUri); + return IsSupported(capabilities) ? SetAppUi(request, resourceUri) : request; + } + + /// + /// Associates an elicitation request with an MCP App UI resource when the requesting client advertised all + /// required capabilities. Uses request-scoped capabilities on 2026-07-28 and session capabilities on legacy + /// stateful connections. + /// + 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 capabilities = context.JsonRpcRequest.Context?.ClientCapabilities ?? context.Server.ClientCapabilities; + return SetAppUiIfSupported(request, capabilities, resourceUri); + } + + private static void ValidateAppUiArguments(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 (!Uri.TryCreate(resourceUri, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, "ui", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("MCP App elicitation resources must use the ui:// URI scheme.", nameof(resourceUri)); + } + } + + /// Gets the app UI metadata from an elicitation request, if present and valid. + 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?[UiMetaKey] is not JsonNode node) + { + return null; + } + + try + { + return node.Deserialize(McpAppsJsonContext.Default.McpAppElicitationMeta); + } + catch (JsonException) + { + return null; + } + } + + /// + /// Returns a typed elicitation response on an MRTR retry, or requests the app-rendered elicitation on the first round. + /// + /// + /// This explicit retry-safe convention is suitable for stateless HTTP. Any operation state needed after the + /// retry must be encoded in the original request arguments or in . + /// Clients that do not support MCP Apps elicitation render the requested schema natively. + /// + public static ElicitResult ResolveOrRequest( + McpServer server, + RequestParams requestParams, + string inputKey, + ElicitRequestParams elicitation, + JsonTypeInfo responseTypeInfo, + string? requestState = null) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(requestParams); + ArgumentException.ThrowIfNullOrWhiteSpace(inputKey); + ArgumentNullException.ThrowIfNull(elicitation); + ArgumentNullException.ThrowIfNull(responseTypeInfo); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); + if (string.IsNullOrWhiteSpace(inputKey)) throw new ArgumentException("The input key is required.", nameof(inputKey)); + if (elicitation is null) throw new ArgumentNullException(nameof(elicitation)); + if (responseTypeInfo is null) throw new ArgumentNullException(nameof(responseTypeInfo)); +#endif + + if (requestParams.InputResponses?.TryGetValue(inputKey, out var response) == true) + { + var raw = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo) + ?? throw new McpProtocolException($"The '{inputKey}' elicitation response was empty.", McpErrorCode.InvalidParams); + + if (!raw.IsAccepted || raw.Content is null) + { + return new ElicitResult { Action = raw.Action }; + } + + JsonObject content = []; + foreach (var item in raw.Content) + { + content[item.Key] = JsonNode.Parse(item.Value.GetRawText()); + } + + var typed = JsonSerializer.Deserialize(content, responseTypeInfo); + return new ElicitResult { Action = raw.Action, Content = typed }; + } + + if (requestParams.InputResponses is { Count: > 0 }) + { + throw new McpProtocolException($"The MRTR retry did not contain the expected '{inputKey}' response.", McpErrorCode.InvalidParams); + } + + if (!server.IsMrtrSupported) + { + throw new InvalidOperationException("App elicitation requires an MRTR-capable client or a stateful transport."); + } + + throw new InputRequiredException( + new Dictionary + { + [inputKey] = InputRequest.ForElicitation(elicitation), + }, + requestState); + } +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs new file mode 100644 index 000000000..db40a7633 --- /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 ui:// resource URI for the elicitation UI. + [JsonPropertyName("resourceUri")] + public string ResourceUri { get; set; } = string.Empty; +} diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs index 53de40759..e74fdae78 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs @@ -111,6 +111,11 @@ private static JsonSerializerOptions CreateSerializerOptions() return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiClientCapabilities); } + if (value is JsonObject jsonObject) + { + return jsonObject.Deserialize(McpAppsJsonContext.Default.McpUiClientCapabilities); + } + return null; } diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs index 8a03ab95d..294d24042 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsJsonContext.cs @@ -14,6 +14,8 @@ namespace ModelContextProtocol.Extensions.Apps; [JsonSerializable(typeof(McpUiResourceMeta))] [JsonSerializable(typeof(McpUiResourceCsp))] [JsonSerializable(typeof(McpUiResourcePermissions))] +[JsonSerializable(typeof(McpUiElicitationCapability))] +[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..31ce4fe97 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpUiClientCapabilities.cs @@ -23,4 +23,10 @@ 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/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs new file mode 100644 index 000000000..543978b89 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -0,0 +1,302 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Moq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Tests.Server; + +public class McpAppElicitationTests +{ + [Fact] + public void AddClientCapabilities_AddsCoreAndAppsElicitationCapabilities() + { + var capabilities = McpAppElicitation.AddClientCapabilities(new ClientCapabilities()); + + Assert.NotNull(capabilities.Elicitation?.Form); + Assert.True(capabilities.Extensions?.ContainsKey("io.modelcontextprotocol/ui")); + Assert.Single(capabilities.Extensions!); + Assert.True(McpAppElicitation.IsSupported(capabilities)); + Assert.NotNull(McpApps.GetUiCapability(capabilities)?.Elicitation); + } + + [Fact] + public void SetAppUi_RoundTripsResourceUri() + { + var request = McpAppElicitation.SetAppUi(CreateRequest(), "ui://portfolio/assign-manager"); + + Assert.Equal("ui://portfolio/assign-manager", McpAppElicitation.GetAppUi(request)?.ResourceUri); + Assert.Equal("ui://portfolio/assign-manager", request.Meta?["ui"]?["resourceUri"]?.GetValue()); + } + + [Fact] + public void SetAppUiIfSupported_FormOnlyClientLeavesCoreRequestUnchanged() + { + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability { Form = new FormElicitationCapability() }, + }; + + var request = McpAppElicitation.SetAppUiIfSupported( + CreateRequest(), + capabilities, + "ui://portfolio/assign-manager"); + + Assert.Null(request.Meta); + Assert.NotNull(request.RequestedSchema); + } + + [Fact] + public void SetAppUiIfSupported_AppElicitationClientAddsResourceUri() + { + var capabilities = McpAppElicitation.AddClientCapabilities(new ClientCapabilities()); + + var request = McpAppElicitation.SetAppUiIfSupported( + CreateRequest(), + capabilities, + "ui://portfolio/assign-manager"); + + Assert.Equal("ui://portfolio/assign-manager", McpAppElicitation.GetAppUi(request)?.ResourceUri); + } + + [Fact] + public void SetAppUiIfSupported_RequestCapabilitiesTakePrecedenceOverServerCapabilities() + { + var server = new Mock(); + server.SetupGet(s => s.ClientCapabilities) + .Returns(McpAppElicitation.AddClientCapabilities(new ClientCapabilities())); + var requestCapabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability { Form = new FormElicitationCapability() }, + }; + var context = new RequestContext( + server.Object, + new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsCall, + Context = new JsonRpcMessageContext + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + ClientCapabilities = requestCapabilities, + }, + }, + new CallToolRequestParams { Name = "assign_account_manager" }); + + var request = McpAppElicitation.SetAppUiIfSupported( + CreateRequest(), + context, + "ui://portfolio/assign-manager"); + + Assert.Null(request.Meta); + } + + [Fact] + public void ResolveOrRequest_FirstRoundReturnsInputRequiredResult() + { + var server = new Mock(); + server.SetupGet(s => s.IsMrtrSupported).Returns(true); + var requestParams = new CallToolRequestParams { Name = "assign_account_manager" }; + var elicitation = McpAppElicitation.SetAppUi(CreateRequest(), "ui://portfolio/assign-manager"); + + var exception = Assert.Throws(() => McpAppElicitation.ResolveOrRequest( + server.Object, + requestParams, + "manager-assignment", + elicitation, + TestJsonContext.Default.AssignmentResponse, + "state-v1")); + + Assert.Equal("state-v1", exception.Result.RequestState); + var input = Assert.Single(exception.Result.InputRequests!); + Assert.Equal("manager-assignment", input.Key); + Assert.Equal(RequestMethods.ElicitationCreate, input.Value.Method); + } + + [Fact] + public void ResolveOrRequest_RetryReturnsTypedContent() + { + var server = new Mock(); + server.SetupGet(s => s.IsMrtrSupported).Returns(true); + var requestParams = new CallToolRequestParams + { + Name = "assign_account_manager", + RequestState = "state-v1", + InputResponses = new Dictionary + { + ["manager-assignment"] = InputResponse.FromElicitResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["confirmed"] = JsonSerializer.SerializeToElement(true), + ["selectedManagerId"] = JsonSerializer.SerializeToElement("mgr-priya"), + }, + }), + }, + }; + + var result = McpAppElicitation.ResolveOrRequest( + server.Object, + requestParams, + "manager-assignment", + CreateRequest(), + TestJsonContext.Default.AssignmentResponse); + + Assert.True(result.IsAccepted); + Assert.True(result.Content?.Confirmed); + Assert.Equal("mgr-priya", result.Content?.SelectedManagerId); + } + + private static ElicitRequestParams CreateRequest() => new() + { + Message = "Choose a manager.", + RequestedSchema = new ElicitRequestParams.RequestSchema(), + }; +} + +public sealed class AssignmentResponse +{ + public bool Confirmed { get; set; } + + public string SelectedManagerId { get; set; } = string.Empty; +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(AssignmentResponse))] +internal sealed partial class TestJsonContext : JsonSerializerContext +{ +} + +#if !NET472 +public sealed class McpAppElicitationCompatibilityTests : ClientServerTestBase +{ + public McpAppElicitationCompatibilityTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices( + Microsoft.Extensions.DependencyInjection.ServiceCollection services, + IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => options.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion); + mcpServerBuilder.WithTools([ + McpServerTool.Create( + CompleteAssignment, + new McpServerToolCreateOptions + { + Name = "complete-assignment", + Description = "Completes an assignment using app-enhanced or native form elicitation.", + }) + ]); + } + + [Fact] + public async Task FormOnlyClient_UsesNativeElicitationAndCompletesMrtrRetry() + { + ElicitRequestParams? observedRequest = null; + var options = CreateClientOptions(new ClientCapabilities + { + Elicitation = new ElicitationCapability { Form = new FormElicitationCapability() }, + }); + options.Handlers.ElicitationHandler = (request, _) => + { + observedRequest = request; + return new ValueTask(CreateAcceptedResult()); + }; + + await using var client = await CreateMcpClientForServer(options); + var result = await client.CallToolAsync( + "complete-assignment", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(observedRequest); + Assert.Null(McpAppElicitation.GetAppUi(observedRequest)); + Assert.NotNull(observedRequest.RequestedSchema); + Assert.Equal("native:mgr-priya", GetText(result)); + } + + [Fact] + public async Task AppElicitationClient_ReceivesResourceHintAndCompletesMrtrRetry() + { + ElicitRequestParams? observedRequest = null; + var options = CreateClientOptions( + McpAppElicitation.AddClientCapabilities(new ClientCapabilities())); + options.Handlers.ElicitationHandler = (request, _) => + { + observedRequest = request; + return new ValueTask(CreateAcceptedResult()); + }; + + await using var client = await CreateMcpClientForServer(options); + var result = await client.CallToolAsync( + "complete-assignment", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(observedRequest); + Assert.Equal( + "ui://portfolio/assign-manager", + McpAppElicitation.GetAppUi(observedRequest)?.ResourceUri); + Assert.Equal("app:mgr-priya", GetText(result)); + } + + private static string CompleteAssignment( + McpServer server, + RequestContext context) + { + var elicitation = McpAppElicitation.SetAppUiIfSupported( + CreateRequest(), + context, + "ui://portfolio/assign-manager"); + var presentation = McpAppElicitation.GetAppUi(elicitation) is null ? "native" : "app"; + var response = McpAppElicitation.ResolveOrRequest( + server, + context.Params, + "manager-assignment", + elicitation, + TestJsonContext.Default.AssignmentResponse, + "state-v1"); + + return $"{presentation}:{response.Content?.SelectedManagerId}"; + } + + private static McpClientOptions CreateClientOptions(ClientCapabilities capabilities) => new() + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + Capabilities = capabilities, + }; + + private static ElicitResult CreateAcceptedResult() => new() + { + Action = "accept", + Content = new Dictionary + { + ["confirmed"] = JsonSerializer.SerializeToElement(true), + ["selectedManagerId"] = JsonSerializer.SerializeToElement("mgr-priya"), + }, + }; + + private static string GetText(CallToolResult result) => + Assert.IsType(Assert.Single(result.Content)).Text; + + private static ElicitRequestParams CreateRequest() => new() + { + Message = "Choose a manager.", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["confirmed"] = new ElicitRequestParams.BooleanSchema(), + ["selectedManagerId"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["confirmed", "selectedManagerId"], + }, + }; +} +#endif