Skip to content

Instantly share code, notes, and snippets.

@krubenok
Last active July 29, 2026 04:28
Show Gist options
  • Select an option

  • Save krubenok/12e195fa34492b388a256e6bdc3b04be to your computer and use it in GitHub Desktop.

Select an option

Save krubenok/12e195fa34492b388a256e6bdc3b04be to your computer and use it in GitHub Desktop.
Experimental MCP Apps elicitations with C# and TypeScript SDK extensions (SEP-3118 point-in-time snapshot)

MCP Apps elicitation: TL;DR and links

This gist is a point-in-time experimental snapshot of MRTR-based MCP Apps elicitations using the draft SEP-3118.

Find the related work

What this demonstrates

  1. The server sends a complete native form elicitation and, when supported, an absolute ui:// MCP App resource hint.
  2. The host renders the App for Accept/Decline/Cancel and returns standard ElicitResult values.
  3. MRTR retries preserve the original arguments, opaque requestState, and request-scoped capabilities.
  4. Unsupported, malformed, or failed App flows fall back to the unchanged native form.
  5. Interoperability is based on canonical wire capabilities, not package identity.

The current gist files contain the runnable C# example, TypeScript host/app guidance, and Inspector test recipe. Configure GitHub Packages with read:packages before restoring or installing dependencies.

Package compatibility

  • C#: Krubenok.ModelContextProtocol.Extensions.Apps.Elicitation 0.3.0-preview.4
  • TypeScript: @krubenok/mcp-apps-elicitation-client 0.1.0-preview.5
  • MCP SDK: 2.0.0-rc.2
  • Stateless MRTR protocol revision: 2026-07-28

The published previews are a dual-shape compatibility snapshot. Hardened releases should use the later C# and TypeScript preview versions described in the session validation notes.

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MCPEXP003;MCPAELICITATION001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.0.0-rc.2" />
<PackageReference Include="Krubenok.ModelContextProtocol.Extensions.Apps.Elicitation"
Version="0.3.0-preview.4" />
</ItemGroup>
<ItemGroup>
<Content Include="ui\choose-option.html" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
using ModelContextProtocol.Extensions.Apps;
using ModelContextProtocol.Server;
using System.ComponentModel;
[McpServerResourceType]
public sealed class ChoiceResources
{
public const string AppResourceUri = "ui://examples/choose-option";
[McpServerResource(
UriTemplate = AppResourceUri,
Name = "choose-option",
MimeType = McpApps.HtmlMimeType)]
[McpMeta("ui", """{"prefersBorder":true}""")]
[Description("MCP App that renders the choose-option form elicitation.")]
public static string GetChooseOptionApp() =>
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "ui", "choose-option.html"));
}
using ModelContextProtocol.Extensions.Apps.Elicitation;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json.Serialization;
[McpServerToolType]
public sealed class ChoiceTools
{
[McpServerTool(Name = "choose_option")]
[Description("Ask the user to choose and confirm one of two options.")]
public static string ChooseOption(
McpServer server,
RequestContext<CallToolRequestParams> context)
{
var elicitation = McpAppElicitation.SetAppUiIfSupported(
new ElicitRequestParams
{
Message = "Choose an option and confirm your selection.",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["selectedOption"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema
{
Title = "Option",
Enum = ["standard", "priority"],
Default = "standard",
},
["confirmed"] = new ElicitRequestParams.BooleanSchema
{
Title = "Confirm selection",
Default = true,
},
},
Required = ["selectedOption", "confirmed"],
},
},
context,
ChoiceResources.AppResourceUri);
var response = McpAppElicitation.ResolveOrRequest(
server,
context.Params,
inputKey: "option-choice",
elicitation,
ExampleJsonContext.Default.OptionChoice,
requestState: "choose-option:v1");
if (!response.IsAccepted || response.Content is null)
{
var disposition = response.Action switch
{
"decline" => "declined",
"cancel" => "canceled",
_ => response.Action,
};
return $"The user {disposition} the choice.";
}
return response.Content.Confirmed
? $"The user selected and confirmed '{response.Content.SelectedOption}'."
: $"The user selected '{response.Content.SelectedOption}' without confirming it.";
}
}
public sealed class OptionChoice
{
public string SelectedOption { get; set; } = string.Empty;
public bool Confirmed { get; set; }
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OptionChoice))]
internal sealed partial class ExampleJsonContext : JsonSerializerContext
{
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choose an option</title>
</head>
<body>
<p id="message">Choose an option.</p>
<label><input type="radio" name="option" value="standard" checked> Standard</label>
<label><input type="radio" name="option" value="priority"> Priority</label>
<button id="confirm">Confirm</button>
<button id="cancel">Cancel</button>
<script>
const initializeRequestId = "app-init";
let elicitationRequestId;
function send(message) {
window.parent.postMessage(message, "*");
}
window.addEventListener("message", event => {
const message = event.data;
if (!message || message.jsonrpc !== "2.0") return;
if (message.id === initializeRequestId && message.result) {
send({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
return;
}
if (message.method === "elicitation/create") {
elicitationRequestId = message.id;
document.getElementById("message").textContent = message.params.message;
}
});
document.getElementById("confirm").addEventListener("click", () => {
const selectedOption =
document.querySelector('input[name="option"]:checked').value;
send({
jsonrpc: "2.0",
id: elicitationRequestId,
result: {
action: "accept",
content: { selectedOption, confirmed: true }
}
});
});
document.getElementById("cancel").addEventListener("click", () => {
send({
jsonrpc: "2.0",
id: elicitationRequestId,
result: { action: "cancel" }
});
});
send({
jsonrpc: "2.0",
id: initializeRequestId,
method: "ui/initialize",
params: {
appInfo: { name: "choose-option-app", version: "1.0.0" },
appCapabilities: { elicitation: {} }
}
});
</script>
</body>
</html>

Test with MCP Inspector

This point-in-time experiment targets:

  • MCP protocol era 2026-07-28 (Modern / sessionless)
  • Krubenok.ModelContextProtocol.Extensions.Apps.Elicitation C# preview.4
  • @krubenok/mcp-apps-elicitation-client TypeScript preview.5
  • Inspector branch u/kyrubeno/inspector-app-elicitations

The experimental client currently advertises both the nested SEP-3118 candidate and the temporary pre-approval gate. Keep those details inside the package helpers so the example can move to standardized APIs without changing its host or tool logic.

Start the generic local fixture

From the Inspector repository:

cd clients/web
npm run test-servers:build
node ../../test-servers/build/server-composable.js \
  --config ../../test-servers/configs/modern-app-elicitation-http.json

The MCP endpoint is:

http://localhost:3102/mcp

In another terminal, from the repository root:

npm run build
MCP_INSPECTOR_API_TOKEN=local-token \
MCP_AUTO_OPEN_ENABLED=false \
npm run web

Open the printed Inspector URL.

Connect and invoke

  1. Add http://localhost:3102/mcp as a Streamable HTTP server.
  2. Open Server Settings → Options.
  3. Set Protocol Era to Modern (2026-07-28, sessionless).
  4. Disconnect and reconnect. The header should show MCP 2026-07-28; a modern connection begins with server/discover, not initialize.
  5. Open Tools, select mrtr_confirm, and execute:
{ "action": "publish demo" }

Expected behavior

The first tools/call returns input_required containing:

  • the complete native form schema (a required boolean confirm field)
  • an opaque requestState
  • _meta.ui.resourceUri pointing to ui://demo/elicitation-confirm.mcp-app.html

Inspector reads that resource over the same MCP connection and opens the App Elicitation Request modal. The App renders Accept, Decline, and Cancel.

The selected action is returned as a standard elicitation result:

{ "action": "accept", "content": { "confirm": true } }

or:

{ "action": "decline" }
{ "action": "cancel" }

Inspector then sends a second tools/call with:

  • a new JSON-RPC id
  • the original tool name and unchanged arguments
  • the echoed requestState
  • inputResponses.confirm containing the App response

The final result includes MRTR complete and the response received by the server.

Wire checks

On every modern request, _meta must include the full request-scoped client capability envelope. With the current experimental packages this contains:

{
  "elicitation": { "form": {} },
  "extensions": {
    "io.modelcontextprotocol/ui": {
      "mimeTypes": ["text/html;profile=mcp-app"],
      "elicitation": {}
    },
    "io.modelcontextprotocol/ui-elicitation": {
      "requires": ["io.modelcontextprotocol/ui"]
    }
  }
}

The separate extension is a temporary opt-in gate, not the proposed final SEP shape. Remove it only through an explicit package migration after SEP approval.

Fail-closed checks

  • Switch the server to Legacy, reconnect, and invoke the tool: the modern request-scoped flow must not silently activate.
  • Remove or corrupt _meta.ui.resourceUri: Inspector must render the unchanged native form instead of an unrelated App.
  • Make the resource or App initialization fail: Inspector must surface a diagnostic and fall back to the complete native form.
using ModelContextProtocol.AspNetCore;
using ModelContextProtocol.Extensions.Apps.Elicitation;
using ModelContextProtocol.Protocol;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer(options =>
{
options.ProtocolVersion = "2026-07-28";
options.ServerInfo = new Implementation
{
Name = "app-elicitation-example",
Version = "1.0.0",
};
options.Capabilities = new ServerCapabilities
{
Tools = new ToolsCapability(),
Resources = new ResourcesCapability(),
};
})
.WithHttpTransport(options => options.Stateless = true)
.WithTools<ChoiceTools>()
.WithResources<ChoiceResources>()
.WithMcpAppElicitation();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run();

Experimental MCP Apps elicitation client

@krubenok/mcp-apps-elicitation-client provides experimental TypeScript client, host, and app conventions for draft SEP-3118. It targets @modelcontextprotocol/client@2.0.0 and supports both direct legacy elicitation requests and Modern 2026-07-28 multi-round-trip requests (MRTR).

This is an unofficial public prototype, not an adopted MCP extension or an official SDK package.

Install

Configure GitHub Packages:

# .npmrc
@krubenok:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
npm install @modelcontextprotocol/client@^2.0.0 \
  @modelcontextprotocol/core@^2.0.0 \
  @modelcontextprotocol/ext-apps@1.7.5 \
  @krubenok/mcp-apps-elicitation-client@0.1.0-preview.5

GitHub Packages requires a token with read:packages, including for public npm packages.

@modelcontextprotocol/ext-apps@1.7.5 still peers on the v1 MCP SDK. This package uses structural adapters so it does not add that SDK as a runtime dependency. In a v2 host, construct AppBridge with null instead of passing the v2 Client; use the originating v2 client explicitly for resource loading.

Capability shape

The canonical SEP-3118 candidate is the nested MCP Apps capability:

{
    "elicitation": { "form": {} },
    "extensions": {
        "io.modelcontextprotocol/ui": {
            "mimeTypes": ["text/html;profile=mcp-app"],
            "elicitation": {}
        }
    }
}

By default, withAppElicitationClientCapabilities() also emits the temporary gate required by Krubenok.ModelContextProtocol.Extensions.Apps.Elicitation@0.2.0-preview.1:

{
    "io.modelcontextprotocol/ui-elicitation": {
        "requires": ["io.modelcontextprotocol/ui"]
    }
}

The separate identifier is an early prototype, not part of SEP-3118. Its requires array is a compatibility hint, not normative extension dependency semantics. For canonical-only testing:

const capabilities = withAppElicitationClientCapabilities({}, { includePrototypeGate: false });

The default remains true for preview.5 compatibility. The helper preserves unrelated capabilities and extension settings, merges MIME and requires arrays without duplicates, and is idempotent.

Interoperability is based only on the wire shape, never on which package constructed it. No receive-side helper reads or requires io.modelcontextprotocol/ui-elicitation.

Client capability input Server behavior
Nested-only canonical shape from a future main MCP Apps client App-rendered elicitation enabled
Dual canonical + prototype shape from this preview.5 package App-rendered elicitation enabled
Canonical shape plus a malformed or unknown prototype gate App-rendered elicitation enabled; gate ignored
Prototype gate only Native form fallback
Base MCP Apps MIME type without nested elicitation Native form fallback
Missing core elicitation.form or MCP App MIME type Native form fallback
Malformed MCP Apps settings or malformed nested elicitation Native form fallback

This means the current separate client package works with a future server or main Apps extension that ignores the prototype gate, and a future nested-only main Apps client works with the separate server package. App/host bridge negotiation follows the same rule: only first-class appCapabilities.elicitation and hostCapabilities.elicitation count; package markers do not.

The removal path is one option change:

withAppElicitationClientCapabilities(existingCapabilities, { includePrototypeGate: false });

Copy-ready Modern client and host

Register the elicitation handler before connect(). Modern clients automatically place the same capabilities in the request-scoped _meta['io.modelcontextprotocol/clientCapabilities'] envelope on every request.

import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
import { Client, StreamableHTTPClientTransport, type ElicitRequest, type ElicitResult } from '@modelcontextprotocol/client';
import {
    MCP_APP_HTML_MIME_TYPE,
    adaptExtAppsBridge,
    preserveAppElicitationCapability,
    registerAppElicitationClient,
    waitForAppInitialized,
    withAppElicitationBridgeCapabilities,
    withAppElicitationClientCapabilities,
    type AppElicitationBridgeSession
} from '@krubenok/mcp-apps-elicitation-client';

declare function mountMcpApp(options: { html: string; resourceUri: string; request: ElicitRequest; signal: AbortSignal }): Promise<{
    transport: Parameters<AppBridge['connect']>[0];
    dispose(): void;
}>;

declare function renderNativeForm(params: ElicitRequest['params'], signal: AbortSignal): Promise<ElicitResult>;

const endpoint = new URL('https://example.com/mcp');
const client = new Client(
    { name: 'example-app-host', version: '1.0.0' },
    {
        versionNegotiation: { mode: { pin: '2026-07-28' } },
        capabilities: withAppElicitationClientCapabilities()
    }
);

registerAppElicitationClient(client, {
    appHost: {
        async open({ client: originatingClient, request, resourceUri, signal }) {
            const resource = await originatingClient.readResource({ uri: resourceUri }, { signal });
            const appHtml = resource.contents.find(content => 'text' in content && content.mimeType?.toLowerCase() === MCP_APP_HTML_MIME_TYPE);
            if (appHtml === undefined || !('text' in appHtml)) {
                throw new TypeError(`Resource ${resourceUri} did not contain MCP App HTML`);
            }

            // This seam is host-specific: create the sandboxed iframe, load appHtml.text,
            // and return the ext-apps transport plus a disposer for that exact instance.
            const mounted = await mountMcpApp({ html: appHtml.text, resourceUri, request, signal });

            // Pass null: ext-apps 1.7.5 peers on the v1 SDK. The v2 client above remains
            // authoritative for this server connection and resource binding.
            const bridge = new AppBridge(null, { name: 'example-app-host', version: '1.0.0' }, withAppElicitationBridgeCapabilities({ logging: {} }));

            // ext-apps 1.7.5 strips unknown appCapabilities members during ui/initialize.
            preserveAppElicitationCapability(bridge);

            try {
                const connecting = bridge.connect(mounted.transport);
                const ready = waitForAppInitialized(bridge, signal);
                await connecting;
                await ready;
            } catch (error) {
                try {
                    await bridge.close();
                } finally {
                    mounted.dispose();
                }
                throw error;
            }

            const adapted = adaptExtAppsBridge(bridge);
            return {
                ...adapted,
                async close() {
                    try {
                        await adapted.close();
                    } finally {
                        mounted.dispose();
                    }
                }
            } satisfies AppElicitationBridgeSession;
        }
    },

    // The unchanged complete requestedSchema is always available here.
    renderNative: (request, signal) => renderNativeForm(request.params, signal),

    // App failures are diagnostic; the package then invokes renderNative.
    onAppError: (error, request) => {
        console.warn('App-rendered elicitation failed; using native form', {
            error,
            method: request.method
        });
    }
});

await client.connect(new StreamableHTTPClientTransport(endpoint));
const result = await client.callTool({ name: 'operation-requiring-input', arguments: {} });

mountMcpApp() and renderNativeForm() are the only host UI seams. The returned bridge session must be bound to the originating Client, elicitation request, resourceUri, and app instance. Never return whichever bridge happens to be active.

The handler uses the app only for form mode with a valid absolute ui:// hint. It falls back to the native renderer when resource loading, sandbox creation, ui/initialize, readiness, capability negotiation, bridge dispatch, or app result validation fails. URL mode always stays on the native path.

Copy-ready app setup

The app declares its bridge capability, preserves the host's matching capability through the ext-apps 1.7.5 result schema, and registers the standard request before connecting:

import { App } from '@modelcontextprotocol/ext-apps';
import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/client';
import { preserveHostAppElicitationCapability, registerAppElicitationApp, supportsAppElicitationBridgeCapability, withAppElicitationBridgeCapabilities } from '@krubenok/mcp-apps-elicitation-client';

declare function renderAppForm(params: ElicitRequestFormParams, signal: AbortSignal): Promise<ElicitResult>;

const app = new App({ name: 'example-elicitation-app', version: '1.0.0' }, withAppElicitationBridgeCapabilities());

preserveHostAppElicitationCapability(app);

registerAppElicitationApp(app, async (request, signal) => {
    if (request.params.mode !== 'form') return { action: 'decline' };
    return renderAppForm(request.params, signal);
});

await app.connect();

if (!supportsAppElicitationBridgeCapability(app.getHostCapabilities())) {
    throw new Error('The host did not negotiate app-rendered elicitation');
}

registerAppElicitationApp() temporarily bypasses only the ext-apps 1.7.5 registration guard for elicitation/create, delegates all other checks, and immediately restores the original guard. Accepted content is validated against the original requestedSchema before it crosses the bridge.

Readiness and ui/initialize

Call preserveAppElicitationCapability(bridge) immediately after constructing AppBridge and before bridge.connect(). It replaces only the constructor-registered ui/initialize handler through ext-apps 1.7.5's private _baseReplaceRequestHandler and _oninitialize seams. The replacement preserves appCapabilities.elicitation, future nested fields, and the original JSON-RPC envelope stripping behavior.

Call preserveHostAppElicitationCapability(app) after constructing App and before app.connect(). It replaces the initialize result schema only for ui/initialize, preserving hostCapabilities.elicitation and future nested fields for getHostCapabilities().

After bridge.connect() starts, await waitForAppInitialized(bridge, signal). It attaches the listener before checking ext-apps' actual _initializedReceived flag, handles already-ready and slow apps, detaches on completion, and rejects on cancellation. onAppInitialized() provides the same race-safe behavior as a callback API. Do not use getAppCapabilities() as a readiness signal: ext-apps records capabilities during ui/initialize, before ui/notifications/initialized.

These helpers intentionally target ext-apps 1.7.5 structural seams and throw TypeError when a required seam is unavailable. Remove them when the minimum ext-apps release natively preserves and types SEP-3118 capabilities and accepts elicitation/create.

MRTR retry behavior

On Modern 2026-07-28, the server returns the standard app-enhanced elicitation/create entry in an input_required result. The v2 client:

  1. dispatches it to the same handler registered by registerAppElicitationClient();
  2. collects the standard ElicitResult;
  3. retries the original request with the bare result under inputResponses;
  4. echoes opaque requestState byte-for-byte; and
  5. repeats until the server returns complete or inputRequired.maxRounds is reached.

No app-specific retry API is needed. The app must return its ElicitResult to the host and must not retry the server operation itself.

Public API

API Purpose
withAppElicitationClientCapabilities() Adds core form, canonical MCP Apps elicitation, MIME type, and the default-on prototype gate.
withAppElicitationBridgeCapabilities() Adds elicitation: {} to app or host bridge capabilities without replacing other settings.
supportsAppElicitationBridgeCapability() Checks the negotiated app or host bridge member.
registerAppElicitationClient() Registers legacy and MRTR client handling with native fallback.
registerAppElicitationApp() Registers the standard app-side elicitation/create handler.
preserveAppElicitationCapability() Preserves app capabilities through ext-apps 1.7.5 ui/initialize.
preserveHostAppElicitationCapability() Preserves host capabilities through ext-apps 1.7.5 ui/initialize.
waitForAppInitialized() / onAppInitialized() Race-safe app readiness APIs.
adaptExtAppsBridge() Adapts the ext-apps bridge to a request-bound session.
validateAppElicitationResult() Validates the core result and accepted content schema.
getAppElicitationResourceUri() Reads and validates an absolute ui:// form hint.

Migration after SEP-3118 standardization

  1. Stop emitting the temporary gate by setting includePrototypeGate: false; a later package revision can flip the single default centrally. No server, host, app, or receive-side logic changes are required.
  2. Upgrade to an ext-apps release whose app and host capability types include elicitation and whose schemas preserve it.
  3. Remove the two preserve*Capability() calls and the app registration guard workaround when ext-apps handles the standardized request natively.
  4. Keep the core client handler, explicit resource binding, native fallback, accepted-content validation, and standard MRTR retry behavior.

Inspector testing

In the Inspector fork, inject withAppElicitationClientCapabilities() into the v2 client before connection and register the handler before the first tool call. Test one app-capable tool returning input_required, then verify the retry contains standard inputResponses and the unchanged requestState. Also test a missing app capability or failed resource load and confirm the same complete schema renders through Inspector's native form path.

The cross-SDK example at examples/apps-elicitation-interop runs this TypeScript client against the C# reference server and covers capability discovery, MRTR auto-fulfillment, resource loading, schema validation, opaque state echo, and the final retry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment