Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/concepts/apps/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,58 @@ public static string GetWeather(McpServer server, string location)
}
```

## App-rendered elicitations

MCP Apps can render standard form-mode `elicitation/create` requests. This uses
the existing `io.modelcontextprotocol/ui` extension; it does not define another
extension or a separate result type.

Call `WithMcpApps()` to advertise
`extensions["io.modelcontextprotocol/ui"].elicitation` from the server. A host
advertises the matching setting, the MCP Apps HTML MIME type, and the core form
elicitation capability:

```csharp
var clientCapabilities = new ClientCapabilities();
McpAppElicitation.AddClientCapabilities(clientCapabilities);
```

On the server, associate a form elicitation with an absolute `ui://` resource
only when the requesting client advertised all three client-side settings:

```csharp
var elicitation = new ElicitRequestParams
{
Message = "Choose a delivery window",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["window"] = new ElicitRequestParams.TitledSingleSelectEnumSchema
{
OneOf =
[
new() { Const = "morning", Title = "Morning" },
new() { Const = "afternoon", Title = "Afternoon" },
],
},
},
Required = ["window"],
},
};

McpAppElicitation.SetAppUiIfSupported(
elicitation,
requestContext,
"ui://delivery/choose-window.html");
```

This adds `_meta.ui.resourceUri` without changing the core elicitation request.
Unsupported clients receive the same request without UI metadata and can render
their native form. The host forwards the request to the selected app and
returns the app's standard `ElicitResult`; validation and MRTR retries remain
part of the core elicitation flow.

## Display modes

The MCP Apps spec defines display modes (`inline`, `fullscreen`, `pip`) that control how the host renders the UI. Display mode is negotiated between the client and server during capability exchange and is not set per-tool — it depends on the host implementation.
Expand Down
211 changes: 211 additions & 0 deletions src/ModelContextProtocol.Extensions.Apps/Server/McpAppElicitation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace ModelContextProtocol.Extensions.Apps;

/// <summary>Provides conventions for using MCP Apps as form elicitation UI.</summary>
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
public static class McpAppElicitation
{
/// <summary>Adds the client capabilities required for app-rendered form elicitation.</summary>
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<string, object>();

JsonObject uiCapabilities;
if (capabilities.Extensions.TryGetValue(McpApps.ExtensionId, out var existing))
{
uiCapabilities = existing switch
{
McpUiClientCapabilities typed => JsonSerializer.SerializeToNode(
typed,
McpAppsJsonContext.Default.McpUiClientCapabilities)!.AsObject(),
JsonObject jsonObject => jsonObject,
JsonElement { ValueKind: JsonValueKind.Object } element =>
JsonNode.Parse(element.GetRawText())!.AsObject(),
_ => [],
};
}
else
{
uiCapabilities = [];
}

if (uiCapabilities["mimeTypes"] is not JsonArray mimeTypes)
{
mimeTypes = [];
uiCapabilities["mimeTypes"] = mimeTypes;
}

if (!mimeTypes.Any(node =>
node is JsonValue value &&
value.TryGetValue<string>(out var mimeType) &&
string.Equals(mimeType, McpApps.HtmlMimeType, StringComparison.OrdinalIgnoreCase)))
{
mimeTypes.Add((JsonNode?)JsonValue.Create(McpApps.HtmlMimeType));
}

uiCapabilities["elicitation"] ??= new JsonObject();
capabilities.Extensions[McpApps.ExtensionId] = uiCapabilities;
return capabilities;
}

/// <summary>
/// Returns whether both peers advertised app-rendered form elicitation
/// through the existing MCP Apps extension.
/// </summary>
public static bool IsSupported(
ClientCapabilities? clientCapabilities,
ServerCapabilities? serverCapabilities)
{
var serverUi = McpApps.GetUiServerCapability(serverCapabilities);

return IsClientSupported(clientCapabilities) &&
serverUi?.Elicitation is not null;
}

/// <summary>
/// Returns whether a client advertised all capabilities required for
/// app-rendered form elicitation.
/// </summary>
/// <remarks>
/// A server using this one-sided check must also advertise
/// <c>io.modelcontextprotocol/ui.elicitation</c>, as <see cref="McpAppsBuilderExtensions.WithMcpApps"/>
/// does.
/// </remarks>
public static bool IsClientSupported(ClientCapabilities? clientCapabilities)
{
var clientUi = McpApps.GetUiCapability(clientCapabilities);
return clientCapabilities?.Elicitation?.Form is not null &&
clientUi?.Elicitation is not null &&
clientUi.MimeTypes?.Contains(McpApps.HtmlMimeType, StringComparer.OrdinalIgnoreCase) == true;
}

/// <summary>Associates a form elicitation request with an MCP App UI resource.</summary>
public static ElicitRequestParams SetAppUi(ElicitRequestParams request, string resourceUri)
{
ValidateArguments(request, resourceUri);

request.Meta ??= [];
request.Meta["ui"] = JsonSerializer.SerializeToNode(
new McpAppElicitationMeta { ResourceUri = resourceUri },
McpAppsJsonContext.Default.McpAppElicitationMeta);
return request;
}

/// <summary>
/// Associates a form elicitation with an MCP App only when both peers
/// negotiated support. Otherwise the request remains a native elicitation.
/// </summary>
public static ElicitRequestParams SetAppUiIfSupported(
ElicitRequestParams request,
ClientCapabilities? clientCapabilities,
ServerCapabilities? serverCapabilities,
string resourceUri)
{
ValidateArguments(request, resourceUri);
return IsSupported(clientCapabilities, serverCapabilities)
? SetAppUi(request, resourceUri)
: request;
}

/// <summary>
/// Associates a form elicitation with an MCP App when the client negotiated
/// support. The server must advertise its side of the setting by calling
/// <see cref="McpAppsBuilderExtensions.WithMcpApps"/>.
/// </summary>
public static ElicitRequestParams SetAppUiIfSupported(
ElicitRequestParams request,
ClientCapabilities? clientCapabilities,
string resourceUri)
{
ValidateArguments(request, resourceUri);
return IsClientSupported(clientCapabilities)
? SetAppUi(request, resourceUri)
: request;
}

/// <summary>
/// Associates a form elicitation with an MCP App using the requesting
/// client's request-scoped capabilities when available.
/// </summary>
public static ElicitRequestParams SetAppUiIfSupported<TParams>(
ElicitRequestParams request,
RequestContext<TParams> context,
string resourceUri)
{
#if NET
ArgumentNullException.ThrowIfNull(context);
#else
if (context is null) throw new ArgumentNullException(nameof(context));
#endif

var clientCapabilities =
context.JsonRpcRequest.Context?.ClientCapabilities ??
context.Server.ClientCapabilities;
return SetAppUiIfSupported(request, clientCapabilities, resourceUri);
}

/// <summary>Gets the MCP App UI metadata from an elicitation request.</summary>
public static McpAppElicitationMeta? GetAppUi(ElicitRequestParams request)
{
#if NET
ArgumentNullException.ThrowIfNull(request);
#else
if (request is null) throw new ArgumentNullException(nameof(request));
#endif

if (request.Meta?["ui"] is not JsonNode node)
{
return null;
}

try
{
var result = node.Deserialize(McpAppsJsonContext.Default.McpAppElicitationMeta);
return result is not null && IsAbsoluteUiUri(result.ResourceUri) ? result : null;
}
catch (JsonException)
{
return null;
}
}

private static void ValidateArguments(ElicitRequestParams request, string resourceUri)
{
#if NET
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(resourceUri);
#else
if (request is null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(resourceUri)) throw new ArgumentException("The resource URI is required.", nameof(resourceUri));
#endif

if (!string.Equals(request.Mode, "form", StringComparison.Ordinal))
{
throw new ArgumentException("MCP Apps only support form-mode elicitations.", nameof(request));
}

if (!IsAbsoluteUiUri(resourceUri))
{
throw new ArgumentException(
"MCP App elicitation resources must be absolute ui:// URIs.",
nameof(resourceUri));
}
}

private static bool IsAbsoluteUiUri(string resourceUri) =>
Uri.TryCreate(resourceUri, UriKind.Absolute, out var uri) &&
string.Equals(uri.Scheme, "ui", StringComparison.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Extensions.Apps;

/// <summary>Associates a form elicitation with the MCP App that should render it.</summary>
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
public sealed class McpAppElicitationMeta
{
/// <summary>Gets or sets the absolute <c>ui://</c> resource URI for the elicitation UI.</summary>
[JsonPropertyName("resourceUri")]
public required string ResourceUri { get; set; }
}
32 changes: 32 additions & 0 deletions src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,38 @@ private static JsonSerializerOptions CreateSerializerOptions()
return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiClientCapabilities);
}

if (value is JsonObject jsonObject)
{
return jsonObject.Deserialize(McpAppsJsonContext.Default.McpUiClientCapabilities);
}

return null;
}

/// <summary>Gets the MCP Apps capability advertised by a server.</summary>
public static McpUiServerCapabilities? GetUiServerCapability(ServerCapabilities? capabilities)
{
if (capabilities?.Extensions is not { } extensions ||
!extensions.TryGetValue(ExtensionId, out var value))
{
return null;
}

if (value is McpUiServerCapabilities uiCapabilities)
{
return uiCapabilities;
}

if (value is JsonObject jsonObject)
{
return jsonObject.Deserialize(McpAppsJsonContext.Default.McpUiServerCapabilities);
}

if (value is JsonElement { ValueKind: JsonValueKind.Object } element)
{
return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiServerCapabilities);
}

return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ public void PostConfigure(string? name, McpServerOptions options)
options.Capabilities.Extensions ??= new Dictionary<string, object>();
if (!options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId))
{
options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject();
options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject
{
["elicitation"] = new System.Text.Json.Nodes.JsonObject(),
};
}

if (options.ToolCollection is { IsEmpty: false } tools)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ namespace ModelContextProtocol.Extensions.Apps;
[JsonSerializable(typeof(McpUiResourceMeta))]
[JsonSerializable(typeof(McpUiResourceCsp))]
[JsonSerializable(typeof(McpUiResourcePermissions))]
[JsonSerializable(typeof(McpUiElicitationCapability))]
[JsonSerializable(typeof(McpUiServerCapabilities))]
[JsonSerializable(typeof(McpAppElicitationMeta))]
internal sealed partial class McpAppsJsonContext : JsonSerializerContext
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ public sealed class McpUiClientCapabilities
/// </remarks>
[JsonPropertyName("mimeTypes")]
public IList<string>? MimeTypes { get; set; }

/// <summary>
/// Gets or sets the capability indicating that the client can render core
/// form elicitations using an MCP App.
/// </summary>
[JsonPropertyName("elicitation")]
public McpUiElicitationCapability? Elicitation { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Extensions.Apps;

/// <summary>Describes support for rendering form elicitations with MCP Apps.</summary>
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
public sealed class McpUiElicitationCapability
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Extensions.Apps;

/// <summary>Represents the MCP Apps capabilities advertised by a server.</summary>
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
public sealed class McpUiServerCapabilities
{
/// <summary>
/// Gets or sets the capability indicating that the server may associate a
/// form elicitation with an MCP App resource.
/// </summary>
[JsonPropertyName("elicitation")]
public McpUiElicitationCapability? Elicitation { get; set; }
}
Loading