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:
commit
a859093bd3
4 changed files with 4824 additions and 139 deletions
241
core/mcp/appElicitation.ts
Normal file
241
core/mcp/appElicitation.ts
Normal 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>;
|
||||
|
|
@ -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] = {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue