Merge remote-tracking branch 'origin/v2/main' into v2/feat/1950-file-secret-store

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	README.md
#	clients/web/src/App.tsx
This commit is contained in:
cliffhall 2026-08-23 01:40:01 -04:00
commit a859093bd3
4 changed files with 4824 additions and 139 deletions

241
core/mcp/appElicitation.ts Normal file
View file

@ -0,0 +1,241 @@
import type {
ClientCapabilities,
ElicitRequest,
ElicitResult,
JsonSchemaType,
jsonSchemaValidator,
ServerCapabilities,
} from "@modelcontextprotocol/client";
import { ElicitResultSchema } from "@modelcontextprotocol/core";
import { MCP_APP_MIME_TYPE, UI_EXTENSION_KEY } from "./extensions.js";
/**
* App-rendered form elicitations (#1854).
*
* A server may attach an MCP App resource to a standard `elicitation/create`
* request; a host that can run MCP Apps renders that app and returns the app's
* ordinary `ElicitResult` to the server. No second extension, no custom method,
* and no custom result shape are introduced — the only new wire surface is a
* nested `elicitation` flag on the existing `io.modelcontextprotocol/ui`
* extension on each side, plus `_meta.ui.resourceUri` on the request.
*
* The helpers here mirror the ext-apps draft (modelcontextprotocol/ext-apps#733,
* SEP-3118) so the Inspector speaks exactly the proposed protocol. They are
* declared locally only because the released `@modelcontextprotocol/ext-apps`
* (1.7.5) predates that PR and exports none of them; replace
* {@link supportsAppElicitation} / {@link getElicitationUiResourceUri} with the
* package's `/server` exports once a release containing #733 ships.
*/
/**
* MCP Apps extension settings advertised by a client, as far as app-rendered
* elicitation cares. Mirrors ext-apps' `McpUiClientCapabilities`.
*/
export interface UiClientCapabilities {
mimeTypes?: string[];
elicitation?: object;
}
/**
* MCP Apps extension settings advertised by a server. Mirrors ext-apps'
* `McpUiServerCapabilities` (introduced by #733): a server sets `elicitation`
* to declare it may attach an App resource to a form elicitation.
*/
export interface UiServerCapabilities {
elicitation?: object;
}
/** Reads the `io.modelcontextprotocol/ui` block out of either side's capabilities. */
function uiExtension(
capabilities: { extensions?: Record<string, unknown> } | null | undefined,
): Record<string, unknown> | undefined {
const ext = capabilities?.extensions?.[UI_EXTENSION_KEY];
return typeof ext === "object" && ext !== null
? (ext as Record<string, unknown>)
: undefined;
}
/** The MCP Apps settings a client advertised, or `undefined`. */
export function getUiClientCapability(
capabilities: ClientCapabilities | null | undefined,
): UiClientCapabilities | undefined {
return uiExtension(capabilities) as UiClientCapabilities | undefined;
}
/** The MCP Apps settings a server advertised, or `undefined`. */
export function getUiServerCapability(
capabilities: ServerCapabilities | null | undefined,
): UiServerCapabilities | undefined {
return uiExtension(capabilities) as UiServerCapabilities | undefined;
}
/**
* Whether both peers negotiated app-rendered form elicitation. All of the
* protocol's conditions except the per-request `_meta` are checked here:
*
* 1. the client advertised core form elicitation (`elicitation.form`);
* 2. the client advertised the MCP Apps MIME type;
* 3. the client advertised the nested MCP Apps `elicitation` setting;
* 4. the server advertised the nested MCP Apps `elicitation` setting.
*
* A MIME-type match alone is deliberately not sufficient — a client that can
* render App *tools* cannot necessarily resolve an elicitation through a bridge.
*/
export function supportsAppElicitation(
clientCapabilities: ClientCapabilities | null | undefined,
serverCapabilities: ServerCapabilities | null | undefined,
): boolean {
const clientUi = getUiClientCapability(clientCapabilities);
const serverUi = getUiServerCapability(serverCapabilities);
return Boolean(
clientCapabilities?.elicitation?.form &&
clientUi?.mimeTypes?.includes(MCP_APP_MIME_TYPE) &&
clientUi.elicitation &&
serverUi?.elicitation,
);
}
const ELICITATION_UI_URI_ERROR =
"Elicitation UI resourceUri must be an absolute ui:// URI";
/**
* Rejects anything that is not an absolute `ui://host/...` URI. Matches the
* ext-apps#733 validator: a bare scheme, a relative reference, or a non-`ui`
* scheme are all unusable and must not reach the renderer.
*/
function validateElicitationUiResourceUri(resourceUri: string): void {
let parsed: URL;
try {
parsed = new URL(resourceUri);
} catch {
throw new Error(ELICITATION_UI_URI_ERROR);
}
if (
parsed.protocol !== "ui:" ||
!/^ui:\/\//i.test(resourceUri) ||
parsed.host.length === 0
) {
throw new Error(ELICITATION_UI_URI_ERROR);
}
}
/**
* Reads `_meta.ui.resourceUri` off an `elicitation/create` request.
*
* Returns `undefined` when the server attached no App (the ordinary case — the
* native elicitation UI handles it). Throws when the metadata is present but
* unusable, so the caller can log the server bug and still fall back rather
* than rendering something arbitrary.
*/
export function getElicitationUiResourceUri(
params: ElicitRequest["params"],
): string | undefined {
const ui = (params._meta as { ui?: unknown } | undefined)?.ui;
if (typeof ui !== "object" || ui === null) return undefined;
const resourceUri = (ui as Record<string, unknown>).resourceUri;
if (resourceUri === undefined) return undefined;
if (typeof resourceUri !== "string") {
throw new Error("Elicitation UI resourceUri must be a string");
}
validateElicitationUiResourceUri(resourceUri);
return resourceUri;
}
/**
* Only `form` mode is app-renderable; an omitted mode IS form (the mode field
* post-dates form elicitation). `url` mode keeps its existing path.
*/
export function isFormElicitation(params: ElicitRequest["params"]): boolean {
const mode = (params as { mode?: unknown }).mode;
return mode === undefined || mode === "form";
}
/**
* The result contract a completed elicitation must satisfy — the standard MCP
* one, not a hand-rolled subset. Checking `action` by hand would accept, for
* instance, `{ action: "decline", content: { x: {} } }`, whose content the
* schema forbids, and hand the server a result it can reject.
*/
const ELICIT_RESULT_ERROR = "App returned an invalid elicitation result";
/**
* Validates what an app returned before it is handed back to the server.
*
* Returns a human-readable reason when the value is not a usable
* `ElicitResult` — an unknown action, an `accept` with no content object, or
* content that fails the request's own `requestedSchema` — and `undefined` when
* it is fine. The value is untrusted (it came from sandboxed app code), so the
* runtime checks stand regardless of the declared type.
*
* A failure here is a fallback trigger, not a protocol error: the host drops to
* the native elicitation UI rather than sending the server something that does
* not match what it asked for.
*/
export function validateAppElicitResult(
provider: jsonSchemaValidator,
params: ElicitRequest["params"],
result: ElicitResult,
): string | undefined {
const parsed = ElicitResultSchema.safeParse(result);
if (!parsed.success) {
return `${ELICIT_RESULT_ERROR}: ${parsed.error.issues[0]?.message ?? "does not match ElicitResult"}`;
}
// decline / cancel carry no content — they are complete as they stand.
if (parsed.data.action !== "accept") return undefined;
const content = parsed.data.content;
if (
typeof content !== "object" ||
content === null ||
Array.isArray(content)
) {
return "App accepted the elicitation without a content object";
}
const schema = (params as { requestedSchema?: unknown }).requestedSchema;
if (typeof schema !== "object" || schema === null) return undefined;
try {
// `requestedSchema` is the SDK's own object-schema shape and `JsonSchemaType`
// the validator provider's third-party JSON Schema interface — structurally
// compatible, nominally unrelated, exactly as in `validateToolOutput`.
const validate = provider.getValidator(schema as JsonSchemaType);
const validation = validate(content);
return validation.valid
? undefined
: `App content does not match the requested schema: ${validation.errorMessage}`;
} catch {
// A schema the validator cannot compile is the server's problem, not the
// app's; don't reject a result over it (mirrors `validateToolOutput`).
return undefined;
}
}
/**
* One app-rendered elicitation, scoped to the originating request.
*
* `requestId` is what makes the association request-scoped: the host keys its
* renderer/bridge by it, so two concurrent elicitations for different resource
* URIs can never resolve through each other's bridges.
*/
export interface AppElicitationRequest {
/** Unique per originating `elicitation/create` request. */
requestId: string;
/** The validated absolute `ui://` URI the app is loaded from. */
resourceUri: string;
/** The original request params, forwarded through the bridge unchanged. */
params: ElicitRequest["params"];
/** Aborts when the originating request is cancelled or the client disconnects. */
signal: AbortSignal;
}
/**
* Host-supplied renderer for {@link AppElicitationRequest}s. Only a client that
* can actually host MCP Apps (today: the web client, when its sandbox renderer
* is available) provides one — providing it is what opts the client into
* advertising the nested MCP Apps `elicitation` capability.
*
* Resolves with the app's standard `ElicitResult`. Rejecting is a request to
* fall back to the native elicitation UI; it must not be used to signal a user
* decision, since `decline` and `cancel` are themselves completed elicitations.
*/
export type AppElicitationRenderer = (
request: AppElicitationRequest,
) => Promise<ElicitResult>;

View file

@ -98,6 +98,19 @@ export interface BuildClientExtensionsInput {
* over the registry's `defaultAdvertised`; an absent key falls back to it.
*/
advertised?: Record<string, boolean>;
/**
* True when this client can render an MCP App and resolve an
* `elicitation/create` request through its bridge (#1854). Adds the nested
* `elicitation` setting to the UI extension's advertisement, which is half of
* the negotiation a server checks before attaching an App to an elicitation.
*
* Deliberately an input rather than a registry default: the shared
* `InspectorClient` knowing the MCP Apps MIME type says nothing about whether
* the *client* has a sandbox renderer, so CLI and TUI must never advertise it.
* Ignored when the UI extension itself is not advertised — a nested setting on
* an extension we did not declare would be meaningless.
*/
appElicitation?: boolean;
}
/**
@ -126,6 +139,14 @@ export function buildClientExtensions(
: {};
}
}
// Nested app-rendered-elicitation opt-in (#1854), layered onto the UI
// extension's own advertisement rather than added as a second extension.
// Guarded on the UI entry actually being present so turning the Apps
// extension off in Server Settings also turns this off.
const uiAdvertisement = map[UI_EXTENSION_KEY];
if (input.appElicitation && uiAdvertisement) {
map[UI_EXTENSION_KEY] = { ...uiAdvertisement, elicitation: {} };
}
if (input.enterpriseManaged) {
map[EMA_EXTENSION_KEY] = {};
}

View file

@ -132,6 +132,13 @@ import {
type ModernDetailedTask,
} from "./modernTaskSchemas.js";
import { buildClientExtensions } from "./extensions.js";
import {
getElicitationUiResourceUri,
isFormElicitation,
supportsAppElicitation,
validateAppElicitResult,
type AppElicitationRenderer,
} from "./appElicitation.js";
import {
EmptyResultSchema,
CallToolResultSchema,
@ -232,6 +239,14 @@ interface ReceiverTaskRecord {
resolvePayload: (payload: ClientResult) => void;
rejectPayload: (reason?: unknown) => void;
cleanupTimeoutId?: ReturnType<typeof setTimeout>;
/**
* Aborted when the task reaches a terminal state some way other than the
* user answering — a `tasks/cancel`, or session teardown. Whatever is
* collecting the answer (the native pending-request entry, or an app-rendered
* elicitation and its bridge) is torn down from this, so a cancelled task
* cannot leave a modal on screen waiting for an answer nothing will read.
*/
abort: AbortController;
}
/**
@ -562,6 +577,38 @@ export class InspectorClient extends InspectorClientEventTarget {
// Per-extension advertise overrides (#1738); undefined key falls back to the
// registry default in ADVERTISABLE_EXTENSIONS.
private readonly advertisedExtensions?: Record<string, boolean>;
/**
* Host-supplied renderer for app-rendered form elicitations (#1854), or
* undefined on a client that cannot host MCP Apps. Its presence is what
* advertises the nested MCP Apps `elicitation` capability, so this is the one
* fact both the advertisement and the routing gate read.
*/
private readonly appElicitationRenderer?: AppElicitationRenderer;
/**
* Monotonic counter behind the request-scoped id handed to the renderer. The
* association MUST be per request (a map keyed by this id owns the resource
* URI, renderer instance and promise on the host side) so two concurrent
* elicitations cannot resolve through each other's bridges.
*/
private appElicitationSeq = 0;
/**
* Per-instance prefix for {@link appElicitationSeq}.
*
* A counter alone is not enough: a host may hold ONE renderer across
* replacement clients (the web client rebuilds its `InspectorClient` on a
* settings change), and every fresh instance would otherwise mint
* `app-elicitation-1` for its first request. Settling that id would then
* resolve — or discard — whichever connection's request the host happened to
* be keying.
*/
private readonly appElicitationPrefix = `app-elicitation-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`;
/**
* Abort controllers for app-rendered elicitations still awaiting an answer.
* Aborted alongside the native pending queue on disconnect, so a rendered app
* cannot outlive the connection that asked for it.
*/
private activeAppElicitations = new Set<AbortController>();
private receiverTaskTtlMs: number | (() => number);
private receiverTaskRecords: Map<string, ReceiverTaskRecord> = new Map();
// OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured")
@ -616,6 +663,7 @@ export class InspectorClient extends InspectorClientEventTarget {
this.elicit = options.elicit ?? true;
this.receiverTasks = options.receiverTasks ?? false;
this.advertisedExtensions = options.advertisedExtensions;
this.appElicitationRenderer = options.appElicitation;
this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000;
this.progress = options.progress ?? true;
this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true;
@ -785,6 +833,14 @@ export class InspectorClient extends InspectorClientEventTarget {
const advertisedExtensions = buildClientExtensions({
enterpriseManaged: options.oauth?.enterpriseManaged ?? false,
advertised: this.advertisedExtensions,
// Read off the built `capabilities.elicitation.form` rather than
// re-deriving from `options.elicit`: the nested MCP Apps `elicitation`
// setting must never be advertised without the core form capability it
// extends, and two derivations of the same fact can drift. Disabling form
// elicitation therefore drops both, as the contract requires. (#1854)
appElicitation:
this.appElicitationRenderer !== undefined &&
capabilities.elicitation?.form !== undefined,
});
if (Object.keys(advertisedExtensions).length > 0) {
capabilities.extensions = {
@ -1174,6 +1230,7 @@ export class InspectorClient extends InspectorClientEventTarget {
payloadPromise,
resolvePayload,
rejectPayload,
abort: new AbortController(),
};
record.cleanupTimeoutId = setTimeout(() => {
record.cleanupTimeoutId = undefined;
@ -1250,6 +1307,9 @@ export class InspectorClient extends InspectorClientEventTarget {
};
record.task = updatedTask;
record.rejectPayload(new Error("Task cancelled"));
// Stop collecting an answer nobody will read: drops the native pending
// entry and tears down an app-rendered elicitation's renderer.
record.abort.abort();
if (record.cleanupTimeoutId != null) {
clearTimeout(record.cleanupTimeoutId);
record.cleanupTimeoutId = undefined;
@ -1379,7 +1439,13 @@ export class InspectorClient extends InspectorClientEventTarget {
// an elicit option that enables no mode advertises nothing, and registering
// regardless throws before the handshake.
if (this.elicitationCapabilityAdvertised && this.client) {
const elicitHandler = (request: ElicitRequest): Promise<ElicitResult> => {
const elicitHandler = (
request: ElicitRequest,
// Structural, and only the one field this needs: the SDK's
// `ClientContext` carries much more, and naming it here would tie the
// handler to a type the bypass helper below does not thread through.
ctx?: { mcpReq?: { signal?: AbortSignal } },
): Promise<ElicitResult> => {
const paramsTask = (request.params as { task?: { ttl?: number } })
?.task;
if (this.tasksCapabilityAdvertised && paramsTask != null) {
@ -1388,35 +1454,74 @@ export class InspectorClient extends InspectorClientEventTarget {
initialStatus: "input_required",
statusMessage: "Awaiting user input",
});
// Settling the receiver task, shared by both answer routes below so
// an app-rendered answer completes the task exactly as a native one
// does.
const completeTask = (result: ElicitResult) => {
// A cancelled (or otherwise terminal) task must not be re-settled:
// an answer that arrives after `tasks/cancel` would otherwise
// overwrite `cancelled` with `completed`.
if (InspectorClient.isTerminalTaskStatus(record.task.status))
return;
record.resolvePayload(result);
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: new Date().toISOString(),
};
record.task = updated;
this.upsertReceiverTask(updated);
};
const failTask = (error: Error) => {
if (InspectorClient.isTerminalTaskStatus(record.task.status))
return;
record.rejectPayload(error);
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: new Date().toISOString(),
statusMessage: error.message,
};
record.task = updated;
this.upsertReceiverTask(updated);
};
void (async () => {
// A task-augmented request is still an `elicitation/create`, so the
// app-rendering contract applies to it too (#1854). It cannot go
// through `enqueuePendingElicitation` — the response frame has
// already been sent as a `CreateTaskResult` and the answer settles
// the TASK rather than the request — so the same attempt is made
// here, falling back to the native queue exactly as that funnel
// does. An abort (disconnect) fails the task rather than reopening
// it natively.
let appResult: ElicitResult | null;
try {
appResult = await this.tryAppElicitation(
request,
record.abort.signal,
);
} catch (error) {
failTask(
error instanceof Error ? error : new Error(String(error)),
);
return;
}
if (appResult) {
completeTask(appResult);
return;
}
const elicitationRequest = new ElicitationCreateMessage(
request,
(result) => {
record.resolvePayload(result);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "completed",
lastUpdatedAt: now,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
completeTask,
(id) => this.removePendingElicitation(id),
(error) => {
record.rejectPayload(error);
const now = new Date().toISOString();
const updated: Task = {
...record.task,
status: "failed",
lastUpdatedAt: now,
statusMessage: error.message,
};
record.task = updated;
this.upsertReceiverTask(updated);
},
failTask,
);
this.addPendingElicitation(elicitationRequest);
// A `tasks/cancel` (or teardown) drops the queued entry, so the
// modal does not outlive the task it belongs to.
this.wirePendingAbort(record.abort.signal, () =>
this.removePendingElicitation(elicitationRequest.id),
);
})();
// Task-augmented (2025-11-25) response — see the sampling handler
// above. Reply with a `CreateTaskResult` (`{ task }`), routed around
@ -1429,7 +1534,19 @@ export class InspectorClient extends InspectorClientEventTarget {
const taskResult: CreateTaskResult = { task: record.task };
return Promise.resolve(taskResult as unknown as ElicitResult);
}
return this.enqueuePendingElicitation(request, "server-request");
// `ctx.mcpReq.signal` aborts when the server cancels this request
// (`notifications/cancelled`). Threading it through means both answer
// surfaces — the native queue entry and an app-rendered elicitation's
// renderer — are torn down with the request, instead of a modal
// outliving work the server abandoned. The task-augmented branch above
// deliberately does NOT use it: that request is answered immediately
// with a `CreateTaskResult`, so its lifetime is the task's, which
// carries its own abort (see `ReceiverTaskRecord.abort`).
return this.enqueuePendingElicitation(
request,
"server-request",
ctx?.mcpReq?.signal,
);
};
this.client.setRequestHandler("elicitation/create", elicitHandler);
// Registration, like the `setRequestHandler` above it — and the whole
@ -1548,6 +1665,9 @@ export class InspectorClient extends InspectorClientEventTarget {
if (record.cleanupTimeoutId != null) {
clearTimeout(record.cleanupTimeoutId);
}
// Same reason as `cancelReceiverTask`: the session that owns whatever is
// collecting the answer is ending.
record.abort.abort();
}
this.receiverTaskRecords.clear();
}
@ -1688,6 +1808,13 @@ export class InspectorClient extends InspectorClientEventTarget {
elicitation.cancel();
}
this.pendingElicitations = [];
// App-rendered elicitations (#1854) are not in the queue above — they live
// in the host's renderer — so abort them here on the same teardown paths.
// `tryAppElicitation` removes each controller in its own `finally`.
for (const controller of this.activeAppElicitations) {
controller.abort();
}
this.activeAppElicitations.clear();
}
/**
@ -1711,7 +1838,12 @@ export class InspectorClient extends InspectorClientEventTarget {
private clearAndAnnouncePendingPeerRequests(): void {
if (
this.pendingSamples.length === 0 &&
this.pendingElicitations.length === 0
this.pendingElicitations.length === 0 &&
// App-rendered elicitations (#1854) are not in either array — they live in
// the host's renderer — so an emptiness check that ignores them lets a
// mid-session close (the `onclose` route, which reaches teardown only
// through here) leave a modal open for a connection that is gone.
this.activeAppElicitations.size === 0
) {
return;
}
@ -2820,11 +2952,17 @@ export class InspectorClient extends InspectorClientEventTarget {
* corresponding `ElicitResult` (echoed to the server on retry); only a
* genuine failure or a `signal` abort rejects.
*/
private enqueuePendingElicitation(
private async enqueuePendingElicitation(
request: ElicitRequest,
origin: PendingRequestOrigin,
signal?: AbortSignal,
): Promise<ElicitResult> {
// App-rendered form elicitation (#1854) is offered first and falls back to
// the native queue below on every failure. Both entry points — the inbound
// `elicitation/create` handler and the MRTR driver's embedded requests —
// funnel through here, so neither can miss the routing.
const appResult = await this.tryAppElicitation(request, signal);
if (appResult) return appResult;
// See {@link enqueuePendingSample} — Promise settle is idempotent.
return new Promise<ElicitResult>((resolvePromise, rejectPromise) => {
const elicitation = new ElicitationCreateMessage(
@ -2842,6 +2980,92 @@ export class InspectorClient extends InspectorClientEventTarget {
});
}
/**
* Attempt to resolve an `elicitation/create` request by rendering the MCP App
* the server attached to it (#1854), returning the app's standard
* `ElicitResult`.
*
* Returns `null` for "not app-rendered — use the native UI", which covers
* every negotiation gate and every failure mode the contract lists: either
* peer did not negotiate the capability, the metadata is absent or unusable,
* the mode is not `form`, the renderer failed (resource read, sandbox/bridge
* init, missing app capability, timeout), or the app returned something that
* is not a valid result for this request. An explicit `decline` or `cancel`
* is a *completed* elicitation and is returned, not fallen back on.
*
* The one case that does not fall back is an abort of the originating
* request: the caller is cancelling the whole elicitation, so re-opening it
* in the native queue would resurrect work the user just abandoned.
*/
private async tryAppElicitation(
request: ElicitRequest,
signal?: AbortSignal,
): Promise<ElicitResult | null> {
const renderer = this.appElicitationRenderer;
if (!renderer) return null;
// Gate 1-3 (client) + 4 (server), from the negotiated capabilities of this
// connection — `this.capabilities` is populated at initialize.
if (!supportsAppElicitation(this.clientCapabilities, this.capabilities)) {
return null;
}
// Only form mode is app-renderable; `url` keeps its existing path.
if (!isFormElicitation(request.params)) return null;
let resourceUri: string | undefined;
try {
resourceUri = getElicitationUiResourceUri(request.params);
} catch (error) {
this.logger.warn(
{ error },
"Elicitation carried unusable _meta.ui.resourceUri; using the native elicitation UI",
);
return null;
}
if (!resourceUri) return null;
// Request-scoped abort: forwards the caller's signal (MRTR cancellation)
// and is aborted by `settleAndDropPendingPeerRequests` on disconnect, so a
// rendered app cannot outlive the connection that asked for it.
// Already cancelled before we got here (the caller aborted while an earlier
// MRTR round was in flight): don't mount an app nobody is waiting on.
if (signal?.aborted) throw createPendingAbortError();
const controller = new AbortController();
const forwardAbort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", forwardAbort, { once: true });
this.activeAppElicitations.add(controller);
try {
const result = await renderer({
requestId: `${this.appElicitationPrefix}-${++this.appElicitationSeq}`,
resourceUri,
params: request.params,
signal: controller.signal,
});
this.outputValidator ??= new AjvJsonSchemaValidator();
const invalid = validateAppElicitResult(
this.outputValidator,
request.params,
result,
);
if (invalid) {
this.logger.warn(
{ resourceUri, reason: invalid },
"App-rendered elicitation returned an invalid result; using the native elicitation UI",
);
return null;
}
return result;
} catch (error) {
if (controller.signal.aborted) throw createPendingAbortError();
this.logger.warn(
{ error, resourceUri },
"App-rendered elicitation failed; using the native elicitation UI",
);
return null;
} finally {
this.activeAppElicitations.delete(controller);
signal?.removeEventListener("abort", forwardAbort);
}
}
/**
* Reject a still-pending request when `signal` aborts (e.g. the user cancels
* the tool call while its MRTR round is awaiting an answer). No-op when

View file

@ -23,6 +23,7 @@ import type { Client } from "@modelcontextprotocol/client";
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
import type { Transport } from "@modelcontextprotocol/client";
import type { InspectorLogger } from "../logging/logger.js";
import type { AppElicitationRenderer } from "./appElicitation.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type {
ClientConfig,
@ -1025,6 +1026,21 @@ export interface InspectorClientOptions {
*/
advertisedExtensions?: Record<string, boolean>;
/**
* Renders an app-rendered form elicitation (#1854) and resolves with the
* app's standard `ElicitResult`.
*
* Supplying this is what opts a client into advertising the nested MCP Apps
* `elicitation` capability — so only a client that can actually host an MCP
* App and drive its bridge should pass one (today: the web client, when the
* sandbox renderer is available). CLI and TUI pass nothing and therefore
* never claim the capability, even though they share this client.
*
* A rejection means "fall back to the native elicitation UI"; a resolved
* `decline`/`cancel` is a completed elicitation and is returned to the server.
*/
appElicitation?: AppElicitationRenderer;
/**
* Whether to enable listChanged notification handlers (default: true)
* If enabled, InspectorClient will subscribe to list_changed notifications and fire