From c07ba8426779f1f14e7bd38670fd13691f296b3f Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 22 Jul 2026 21:18:51 -0700 Subject: [PATCH 1/2] Prototype MCP Apps elicitation extension --- ModelContextProtocol.slnx | 3 + docs/extensions/apps-elicitation.md | 115 +++++++ samples/AppElicitation/README.md | 33 ++ .../AppElicitationHost.csproj | 18 ++ .../PendingElicitationStore.cs | 82 +++++ samples/AppElicitationHost/Program.cs | 76 +++++ samples/AppElicitationHost/wwwroot/index.html | 95 ++++++ .../AppElicitationServer.csproj | 19 ++ .../PortfolioResources.cs | 18 ++ .../AppElicitationServer/PortfolioTools.cs | 89 ++++++ samples/AppElicitationServer/Program.cs | 27 ++ .../ui/assign-manager.html | 83 +++++ .../McpAppElicitation.cs | 220 +++++++++++++ .../McpAppElicitationBuilderExtensions.cs | 40 +++ .../McpAppElicitationCapability.cs | 11 + .../McpAppElicitationJsonContext.cs | 12 + .../McpAppElicitationMeta.cs | 11 + ...rotocol.Extensions.Apps.Elicitation.csproj | 40 +++ .../README.md | 24 ++ .../ModelContextProtocol.Tests.csproj | 1 + .../Server/McpAppElicitationTests.cs | 299 ++++++++++++++++++ 21 files changed, 1316 insertions(+) create mode 100644 docs/extensions/apps-elicitation.md create mode 100644 samples/AppElicitation/README.md create mode 100644 samples/AppElicitationHost/AppElicitationHost.csproj create mode 100644 samples/AppElicitationHost/PendingElicitationStore.cs create mode 100644 samples/AppElicitationHost/Program.cs create mode 100644 samples/AppElicitationHost/wwwroot/index.html create mode 100644 samples/AppElicitationServer/AppElicitationServer.csproj create mode 100644 samples/AppElicitationServer/PortfolioResources.cs create mode 100644 samples/AppElicitationServer/PortfolioTools.cs create mode 100644 samples/AppElicitationServer/Program.cs create mode 100644 samples/AppElicitationServer/ui/assign-manager.html create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitation.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj create mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 9020d2fbe..7dcfa95fd 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -40,6 +40,8 @@ + + @@ -69,6 +71,7 @@ + diff --git a/docs/extensions/apps-elicitation.md b/docs/extensions/apps-elicitation.md new file mode 100644 index 000000000..f0abdf83d --- /dev/null +++ b/docs/extensions/apps-elicitation.md @@ -0,0 +1,115 @@ +# 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. + +## Why a separate extension capability? + +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. Advertising only `io.modelcontextprotocol/ui` would make that support ambiguous. +This prototype therefore adds a dependent extension: + +```json +{ + "capabilities": { + "elicitation": { "form": {} }, + "extensions": { + "io.modelcontextprotocol/ui": { + "mimeTypes": ["text/html;profile=mcp-app"] + }, + "io.modelcontextprotocol/ui-elicitation": { + "requires": ["io.modelcontextprotocol/ui"] + } + } + } +} +``` + +The identifier and `requires` member are experimental. They demonstrate dependency and negotiation semantics; +they do not claim adoption by the MCP project. + +## 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 both extensions 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 either app extension, 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 + +- `WithMcpAppElicitation()` advertises this extension and the required MCP Apps extension. +- `AddClientCapabilities(...)` advertises form elicitation plus both extension capabilities. +- `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 a first-class capability rather than `experimental`? +3. Should `_meta.ui.resourceUri` alone opt into routing, or must both extension capabilities always 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..12f4c38bc --- /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, MCP Apps, and the app-elicitation extension. +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..33a83aa14 --- /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..521a32695 --- /dev/null +++ b/samples/AppElicitationHost/Program.cs @@ -0,0 +1,76 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps.Elicitation; +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..536eb43db --- /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..2ee9e0252 --- /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..c7ea49c1e --- /dev/null +++ b/samples/AppElicitationServer/PortfolioTools.cs @@ -0,0 +1,89 @@ +using ModelContextProtocol.Extensions.Apps.Elicitation; +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..7fa11ca4a --- /dev/null +++ b/samples/AppElicitationServer/Program.cs @@ -0,0 +1,27 @@ +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Extensions.Apps.Elicitation; +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() + .WithMcpAppElicitation(); + +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..287327237 --- /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.Elicitation/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitation.cs new file mode 100644 index 000000000..5f6fd18d0 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitation.cs @@ -0,0 +1,220 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Extensions.Apps.Elicitation; + +/// Strongly typed conventions for using MCP Apps as form elicitation UI. +public static class McpAppElicitation +{ + /// The experimental extension identifier. + public const string ExtensionId = "io.modelcontextprotocol/ui-elicitation"; + + /// 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.ContainsKey(McpApps.ExtensionId)) + { + capabilities.Extensions[McpApps.ExtensionId] = new JsonObject + { + ["mimeTypes"] = new JsonArray(McpApps.HtmlMimeType), + }; + } + + if (!capabilities.Extensions.ContainsKey(ExtensionId)) + { + capabilities.Extensions[ExtensionId] = new JsonObject + { + ["requires"] = new JsonArray(McpApps.ExtensionId), + }; + } + + return capabilities; + } + + /// Returns whether the client advertised form elicitation, MCP Apps, and this extension. + public static bool IsSupported(ClientCapabilities? capabilities) => + capabilities?.Elicitation?.Form is not null && + HasAppsCapability(capabilities) && + capabilities.Extensions?.TryGetValue(ExtensionId, out var value) == true && + IsCapabilityValue(value); + + /// 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 }, + McpAppElicitationJsonContext.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(McpAppElicitationJsonContext.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 this extension can ignore _meta.ui and 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); + } + + private static bool IsCapabilityValue(object? value) => value switch + { + McpAppElicitationCapability => true, + JsonObject => true, + JsonElement { ValueKind: JsonValueKind.Object } => true, + _ => false, + }; + + private static bool HasAppsCapability(ClientCapabilities capabilities) => + McpApps.GetUiCapability(capabilities) is not null || + capabilities.Extensions?.TryGetValue(McpApps.ExtensionId, out var value) == true && + value is JsonObject; +} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs new file mode 100644 index 000000000..66370304c --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Apps.Elicitation; + +/// Builder extensions for app-rendered elicitation. +public static class McpAppElicitationBuilderExtensions +{ + /// Enables the app-elicitation extension and its required MCP Apps extension. + public static IMcpServerBuilder WithMcpAppElicitation(this IMcpServerBuilder builder) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); +#endif + builder.WithMcpApps(); + builder.Services.AddSingleton, PostConfigureOptions>(); + return builder; + } + + private sealed class PostConfigureOptions : IPostConfigureOptions + { + public void PostConfigure(string? name, McpServerOptions options) + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(McpAppElicitation.ExtensionId)) + { + options.Capabilities.Extensions[McpAppElicitation.ExtensionId] = new JsonObject + { + ["requires"] = new JsonArray(McpApps.ExtensionId), + }; + } + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs new file mode 100644 index 000000000..f97e0a206 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps.Elicitation; + +/// Describes support for rendering form elicitations with MCP Apps. +public sealed class McpAppElicitationCapability +{ + /// Gets the extensions that must also be negotiated. + [JsonPropertyName("requires")] + public IList Requires { get; set; } = [McpApps.ExtensionId]; +} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs new file mode 100644 index 000000000..fd12e6650 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps.Elicitation; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(McpAppElicitationCapability))] +[JsonSerializable(typeof(McpAppElicitationMeta))] +internal sealed partial class McpAppElicitationJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs new file mode 100644 index 000000000..50ef1b01d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Apps.Elicitation; + +/// Associates a form elicitation with the MCP App that should render it. +public sealed class McpAppElicitationMeta +{ + /// Gets or sets the ui:// resource URI for the elicitation UI. + [JsonPropertyName("resourceUri")] + public required string ResourceUri { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj b/src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj new file mode 100644 index 000000000..dae9d0c2f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj @@ -0,0 +1,40 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Apps.Elicitation + Experimental conventions for using MCP Apps as custom form elicitation UI. + README.md + false + $(NoWarn);MCPEXP003 + + + + true + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md b/src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md new file mode 100644 index 000000000..95150160a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md @@ -0,0 +1,24 @@ +# MCP Apps Elicitation prototype + +This experimental package composes three negotiated features: + +- core form elicitation; +- `io.modelcontextprotocol/ui` (MCP Apps); +- `io.modelcontextprotocol/ui-elicitation` (this prototype). + +An elicitation keeps its normal `requestedSchema` fallback and adds the MCP Apps resource link proposed in +modelcontextprotocol/ext-apps#511: + +```json +"_meta": { "ui": { "resourceUri": "ui://portfolio/assign-manager" } } +``` + +`McpAppElicitation.ResolveOrRequest` implements the explicit MRTR convention required by a stateless +2026-07-28 server: the first invocation returns `InputRequiredResult`; the client resolves the app UI and retries; +the second invocation deserializes the matching `inputResponses` entry into `T`. + +Use `SetAppUiIfSupported(request, context, resourceUri)` in a tool to read the authoritative request-scoped +capabilities on 2026-07-28. Clients that advertise core form elicitation without both app extensions receive the +same request without `_meta.ui` and render `requestedSchema` using their native elicitation UI. + +This is a reference implementation for discussion, not an adopted MCP extension. diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 677d77357..7a405713e 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -84,6 +84,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs new file mode 100644 index 000000000..c59304524 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -0,0 +1,299 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps.Elicitation; +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_AddsCoreAppsAndDependentExtension() + { + var capabilities = McpAppElicitation.AddClientCapabilities(new ClientCapabilities()); + + Assert.NotNull(capabilities.Elicitation?.Form); + Assert.True(capabilities.Extensions?.ContainsKey("io.modelcontextprotocol/ui")); + Assert.True(capabilities.Extensions?.ContainsKey(McpAppElicitation.ExtensionId)); + Assert.True(McpAppElicitation.IsSupported(capabilities)); + } + + [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 From ac13c74d5b6b5e5a6427e319ba97c22f6c1f01ff Mon Sep 17 00:00:00 2001 From: Kyle Rubenok Date: Wed, 22 Jul 2026 21:35:37 -0700 Subject: [PATCH 2/2] Fold app elicitation into the MCP Apps extension --- ModelContextProtocol.slnx | 1 - docs/concepts/apps/apps.md | 37 +++++++++ docs/extensions/apps-elicitation.md | 28 ++++--- samples/AppElicitation/README.md | 2 +- .../AppElicitationHost.csproj | 2 +- samples/AppElicitationHost/Program.cs | 2 +- samples/AppElicitationHost/wwwroot/index.html | 2 +- .../AppElicitationServer.csproj | 2 +- .../AppElicitationServer/PortfolioTools.cs | 2 +- samples/AppElicitationServer/Program.cs | 4 +- .../ui/assign-manager.html | 2 +- .../McpAppElicitationBuilderExtensions.cs | 40 ---------- .../McpAppElicitationCapability.cs | 11 --- .../McpAppElicitationJsonContext.cs | 12 --- ...rotocol.Extensions.Apps.Elicitation.csproj | 40 ---------- .../README.md | 24 ------ .../Server}/McpAppElicitation.cs | 75 +++++++++++-------- .../Server}/McpAppElicitationMeta.cs | 6 +- .../Server/McpApps.cs | 5 ++ .../Server/McpAppsJsonContext.cs | 2 + .../Server/McpUiClientCapabilities.cs | 6 ++ .../Server/McpUiElicitationCapability.cs | 9 +++ .../ModelContextProtocol.Tests.csproj | 1 - .../Server/McpAppElicitationTests.cs | 9 ++- 24 files changed, 135 insertions(+), 189 deletions(-) delete mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs delete mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs delete mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs delete mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj delete mode 100644 src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md rename src/{ModelContextProtocol.Extensions.Apps.Elicitation => ModelContextProtocol.Extensions.Apps/Server}/McpAppElicitation.cs (77%) rename src/{ModelContextProtocol.Extensions.Apps.Elicitation => ModelContextProtocol.Extensions.Apps/Server}/McpAppElicitationMeta.cs (57%) create mode 100644 src/ModelContextProtocol.Extensions.Apps/Server/McpUiElicitationCapability.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 7dcfa95fd..d2810ff3f 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -71,7 +71,6 @@ - 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 index f0abdf83d..f8f6dccc5 100644 --- a/docs/extensions/apps-elicitation.md +++ b/docs/extensions/apps-elicitation.md @@ -4,11 +4,11 @@ This prototype composes core form elicitation, MCP Apps, and Multi Round-Trip Re interoperable flow. It is informed by ext-apps issue #511, discussion #514, PR #531, and the deferred-tool workaround in PR #390. -## Why a separate extension capability? +## 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. Advertising only `io.modelcontextprotocol/ui` would make that support ambiguous. -This prototype therefore adds a dependent extension: +lifecycle, or fall back safely. This prototype adds an optional `elicitation` member to the existing MCP Apps +client capability: ```json { @@ -16,18 +16,16 @@ This prototype therefore adds a dependent extension: "elicitation": { "form": {} }, "extensions": { "io.modelcontextprotocol/ui": { - "mimeTypes": ["text/html;profile=mcp-app"] - }, - "io.modelcontextprotocol/ui-elicitation": { - "requires": ["io.modelcontextprotocol/ui"] + "mimeTypes": ["text/html;profile=mcp-app"], + "elicitation": {} } } } } ``` -The identifier and `requires` member are experimental. They demonstrate dependency and negotiation semantics; -they do not claim adoption by the MCP project. +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 @@ -55,11 +53,11 @@ issue #511: } ``` -A host supporting both extensions reads and renders the resource, then forwards `elicitation/create` to that app +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 either app extension, so +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`. @@ -88,8 +86,8 @@ performing non-idempotent work before the elicitation has resolved. ## C# API shape -- `WithMcpAppElicitation()` advertises this extension and the required MCP Apps extension. -- `AddClientCapabilities(...)` advertises form elicitation plus both extension capabilities. +- `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. @@ -108,8 +106,8 @@ performing non-idempotent work before the elicitation has resolved. ## 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 a first-class capability rather than `experimental`? -3. Should `_meta.ui.resourceUri` alone opt into routing, or must both extension capabilities always be present? +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 index 12f4c38bc..d17183483 100644 --- a/samples/AppElicitation/README.md +++ b/samples/AppElicitation/README.md @@ -11,7 +11,7 @@ The server is deliberately stateless and pins the draft `2026-07-28` protocol. T 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, MCP Apps, and the app-elicitation extension. + 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. diff --git a/samples/AppElicitationHost/AppElicitationHost.csproj b/samples/AppElicitationHost/AppElicitationHost.csproj index 33a83aa14..073467368 100644 --- a/samples/AppElicitationHost/AppElicitationHost.csproj +++ b/samples/AppElicitationHost/AppElicitationHost.csproj @@ -8,7 +8,7 @@ - + diff --git a/samples/AppElicitationHost/Program.cs b/samples/AppElicitationHost/Program.cs index 521a32695..e506681eb 100644 --- a/samples/AppElicitationHost/Program.cs +++ b/samples/AppElicitationHost/Program.cs @@ -1,5 +1,5 @@ using ModelContextProtocol.Client; -using ModelContextProtocol.Extensions.Apps.Elicitation; +using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; var builder = WebApplication.CreateBuilder(args); diff --git a/samples/AppElicitationHost/wwwroot/index.html b/samples/AppElicitationHost/wwwroot/index.html index 536eb43db..ea1e20742 100644 --- a/samples/AppElicitationHost/wwwroot/index.html +++ b/samples/AppElicitationHost/wwwroot/index.html @@ -48,7 +48,7 @@

Custom MCP App elicitation

result: { protocolVersion: "2026-07-28", hostInfo: { name: "minimal-app-elicitation-host", version: "0.1.0" }, - hostCapabilities: { experimental: { "io.modelcontextprotocol/ui-elicitation": {} } }, + hostCapabilities: { elicitation: {} }, hostContext: { theme: "light", displayMode: "inline" } } }, "*"); diff --git a/samples/AppElicitationServer/AppElicitationServer.csproj b/samples/AppElicitationServer/AppElicitationServer.csproj index 2ee9e0252..3c848ea82 100644 --- a/samples/AppElicitationServer/AppElicitationServer.csproj +++ b/samples/AppElicitationServer/AppElicitationServer.csproj @@ -9,7 +9,7 @@ - + diff --git a/samples/AppElicitationServer/PortfolioTools.cs b/samples/AppElicitationServer/PortfolioTools.cs index c7ea49c1e..cffdd3b60 100644 --- a/samples/AppElicitationServer/PortfolioTools.cs +++ b/samples/AppElicitationServer/PortfolioTools.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol.Extensions.Apps.Elicitation; +using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; diff --git a/samples/AppElicitationServer/Program.cs b/samples/AppElicitationServer/Program.cs index 7fa11ca4a..0fc910010 100644 --- a/samples/AppElicitationServer/Program.cs +++ b/samples/AppElicitationServer/Program.cs @@ -1,5 +1,5 @@ using ModelContextProtocol.AspNetCore; -using ModelContextProtocol.Extensions.Apps.Elicitation; +using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; Console.WriteLine("Configuring the stateless MCP app-elicitation server..."); @@ -19,7 +19,7 @@ .WithHttpTransport(options => options.Stateless = true) .WithTools() .WithResources() - .WithMcpAppElicitation(); + .WithMcpApps(); var app = builder.Build(); app.MapMcp("/mcp"); diff --git a/samples/AppElicitationServer/ui/assign-manager.html b/samples/AppElicitationServer/ui/assign-manager.html index 287327237..8face735d 100644 --- a/samples/AppElicitationServer/ui/assign-manager.html +++ b/samples/AppElicitationServer/ui/assign-manager.html @@ -75,7 +75,7 @@

Contoso

method: "ui/initialize", params: { appInfo: { name: "portfolio-assignment-ui", version: "0.1.0" }, - appCapabilities: { experimental: { "io.modelcontextprotocol/ui-elicitation": {} } } + appCapabilities: { elicitation: {} } } }); diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs deleted file mode 100644 index 66370304c..000000000 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationBuilderExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Extensions.Apps.Elicitation; - -/// Builder extensions for app-rendered elicitation. -public static class McpAppElicitationBuilderExtensions -{ - /// Enables the app-elicitation extension and its required MCP Apps extension. - public static IMcpServerBuilder WithMcpAppElicitation(this IMcpServerBuilder builder) - { -#if NET - ArgumentNullException.ThrowIfNull(builder); -#else - if (builder is null) throw new ArgumentNullException(nameof(builder)); -#endif - builder.WithMcpApps(); - builder.Services.AddSingleton, PostConfigureOptions>(); - return builder; - } - - private sealed class PostConfigureOptions : IPostConfigureOptions - { - public void PostConfigure(string? name, McpServerOptions options) - { - options.Capabilities ??= new ServerCapabilities(); - options.Capabilities.Extensions ??= new Dictionary(); - if (!options.Capabilities.Extensions.ContainsKey(McpAppElicitation.ExtensionId)) - { - options.Capabilities.Extensions[McpAppElicitation.ExtensionId] = new JsonObject - { - ["requires"] = new JsonArray(McpApps.ExtensionId), - }; - } - } - } -} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs deleted file mode 100644 index f97e0a206..000000000 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationCapability.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Extensions.Apps.Elicitation; - -/// Describes support for rendering form elicitations with MCP Apps. -public sealed class McpAppElicitationCapability -{ - /// Gets the extensions that must also be negotiated. - [JsonPropertyName("requires")] - public IList Requires { get; set; } = [McpApps.ExtensionId]; -} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs b/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs deleted file mode 100644 index fd12e6650..000000000 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationJsonContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Extensions.Apps.Elicitation; - -[JsonSourceGenerationOptions( - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(McpAppElicitationCapability))] -[JsonSerializable(typeof(McpAppElicitationMeta))] -internal sealed partial class McpAppElicitationJsonContext : JsonSerializerContext -{ -} diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj b/src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj deleted file mode 100644 index dae9d0c2f..000000000 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/ModelContextProtocol.Extensions.Apps.Elicitation.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - - net10.0;net9.0;net8.0;netstandard2.0 - true - true - ModelContextProtocol.Extensions.Apps.Elicitation - Experimental conventions for using MCP Apps as custom form elicitation UI. - README.md - false - $(NoWarn);MCPEXP003 - - - - true - - - - $(NoWarn);CS0436 - - - - - - - - - - - - - - - - - - diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md b/src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md deleted file mode 100644 index 95150160a..000000000 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# MCP Apps Elicitation prototype - -This experimental package composes three negotiated features: - -- core form elicitation; -- `io.modelcontextprotocol/ui` (MCP Apps); -- `io.modelcontextprotocol/ui-elicitation` (this prototype). - -An elicitation keeps its normal `requestedSchema` fallback and adds the MCP Apps resource link proposed in -modelcontextprotocol/ext-apps#511: - -```json -"_meta": { "ui": { "resourceUri": "ui://portfolio/assign-manager" } } -``` - -`McpAppElicitation.ResolveOrRequest` implements the explicit MRTR convention required by a stateless -2026-07-28 server: the first invocation returns `InputRequiredResult`; the client resolves the app UI and retries; -the second invocation deserializes the matching `inputResponses` entry into `T`. - -Use `SetAppUiIfSupported(request, context, resourceUri)` in a tool to read the authoritative request-scoped -capabilities on 2026-07-28. Clients that advertise core form elicitation without both app extensions receive the -same request without `_meta.ui` and render `requestedSchema` using their native elicitation UI. - -This is a reference implementation for discussion, not an adopted MCP extension. diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitation.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs similarity index 77% rename from src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitation.cs rename to src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs index 5f6fd18d0..7a20649e3 100644 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitation.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs @@ -1,17 +1,16 @@ 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.Elicitation; +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 experimental extension identifier. - public const string ExtensionId = "io.modelcontextprotocol/ui-elicitation"; - /// The metadata member inherited from the MCP Apps extension. public const string UiMetaKey = "ui"; @@ -28,31 +27,58 @@ public static ClientCapabilities AddClientCapabilities(ClientCapabilities capabi capabilities.Elicitation.Form ??= new FormElicitationCapability(); capabilities.Extensions ??= new Dictionary(); - if (!capabilities.Extensions.ContainsKey(McpApps.ExtensionId)) + if (!capabilities.Extensions.TryGetValue(McpApps.ExtensionId, out var value)) { capabilities.Extensions[McpApps.ExtensionId] = new JsonObject { ["mimeTypes"] = new JsonArray(McpApps.HtmlMimeType), + ["elicitation"] = new JsonObject(), }; } - - if (!capabilities.Extensions.ContainsKey(ExtensionId)) + else { - capabilities.Extensions[ExtensionId] = new JsonObject + var appCapabilities = value switch { - ["requires"] = new JsonArray(McpApps.ExtensionId), + 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, MCP Apps, and this extension. - public static bool IsSupported(ClientCapabilities? capabilities) => - capabilities?.Elicitation?.Form is not null && - HasAppsCapability(capabilities) && - capabilities.Extensions?.TryGetValue(ExtensionId, out var value) == true && - IsCapabilityValue(value); + /// 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) @@ -62,7 +88,7 @@ public static ElicitRequestParams SetAppUi(ElicitRequestParams request, string r request.Meta ??= []; request.Meta[UiMetaKey] = JsonSerializer.SerializeToNode( new McpAppElicitationMeta { ResourceUri = resourceUri }, - McpAppElicitationJsonContext.Default.McpAppElicitationMeta); + McpAppsJsonContext.Default.McpAppElicitationMeta); return request; } @@ -129,7 +155,7 @@ private static void ValidateAppUiArguments(ElicitRequestParams request, string r try { - return node.Deserialize(McpAppElicitationJsonContext.Default.McpAppElicitationMeta); + return node.Deserialize(McpAppsJsonContext.Default.McpAppElicitationMeta); } catch (JsonException) { @@ -143,7 +169,7 @@ private static void ValidateAppUiArguments(ElicitRequestParams request, string r /// /// 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 this extension can ignore _meta.ui and render the requested schema natively. + /// Clients that do not support MCP Apps elicitation render the requested schema natively. /// public static ElicitResult ResolveOrRequest( McpServer server, @@ -204,17 +230,4 @@ public static ElicitResult ResolveOrRequest( }, requestState); } - - private static bool IsCapabilityValue(object? value) => value switch - { - McpAppElicitationCapability => true, - JsonObject => true, - JsonElement { ValueKind: JsonValueKind.Object } => true, - _ => false, - }; - - private static bool HasAppsCapability(ClientCapabilities capabilities) => - McpApps.GetUiCapability(capabilities) is not null || - capabilities.Extensions?.TryGetValue(McpApps.ExtensionId, out var value) == true && - value is JsonObject; } diff --git a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs similarity index 57% rename from src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs rename to src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs index 50ef1b01d..db40a7633 100644 --- a/src/ModelContextProtocol.Extensions.Apps.Elicitation/McpAppElicitationMeta.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitationMeta.cs @@ -1,11 +1,13 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Extensions.Apps.Elicitation; +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 required string ResourceUri { get; set; } + 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/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 7a405713e..677d77357 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -84,7 +84,6 @@ - diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs index c59304524..543978b89 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppElicitationTests.cs @@ -1,6 +1,8 @@ +#pragma warning disable MCPEXP003 + using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; -using ModelContextProtocol.Extensions.Apps.Elicitation; +using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Moq; @@ -12,14 +14,15 @@ namespace ModelContextProtocol.Tests.Server; public class McpAppElicitationTests { [Fact] - public void AddClientCapabilities_AddsCoreAppsAndDependentExtension() + public void AddClientCapabilities_AddsCoreAndAppsElicitationCapabilities() { var capabilities = McpAppElicitation.AddClientCapabilities(new ClientCapabilities()); Assert.NotNull(capabilities.Elicitation?.Form); Assert.True(capabilities.Extensions?.ContainsKey("io.modelcontextprotocol/ui")); - Assert.True(capabilities.Extensions?.ContainsKey(McpAppElicitation.ExtensionId)); + Assert.Single(capabilities.Extensions!); Assert.True(McpAppElicitation.IsSupported(capabilities)); + Assert.NotNull(McpApps.GetUiCapability(capabilities)?.Elicitation); } [Fact]