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