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
2 changes: 2 additions & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
<Project Path="docs/concepts/progress/samples/server/Progress.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AppElicitationHost/AppElicitationHost.csproj" />
<Project Path="samples/AppElicitationServer/AppElicitationServer.csproj" />
<Project Path="samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj" />
<Project Path="samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj" />
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
Expand Down
37 changes: 37 additions & 0 deletions docs/concepts/apps/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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:
Expand Down
113 changes: 113 additions & 0 deletions docs/extensions/apps-elicitation.md
Original file line number Diff line number Diff line change
@@ -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<T>(...)` 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?
33 changes: 33 additions & 0 deletions samples/AppElicitation/README.md
Original file line number Diff line number Diff line change
@@ -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 <http://localhost:5200> 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.
18 changes: 18 additions & 0 deletions samples/AppElicitationHost/AppElicitationHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MCPEXP003</NoWarn>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Extensions.Apps\ModelContextProtocol.Extensions.Apps.csproj" />
</ItemGroup>

<ItemGroup>
<Content Update="wwwroot\index.html" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

</Project>
82 changes: 82 additions & 0 deletions samples/AppElicitationHost/PendingElicitationStore.cs
Original file line number Diff line number Diff line change
@@ -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<ElicitResult> 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<ElicitResult> 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<ElicitResult> Completion { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}

public sealed class SubmitElicitation
{
public string Action { get; set; } = "cancel";
public IDictionary<string, JsonElement>? Content { get; set; }
}
76 changes: 76 additions & 0 deletions samples/AppElicitationHost/Program.cs
Original file line number Diff line number Diff line change
@@ -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<TextResourceContents>().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<TextContentBlock>().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");
Loading