Skip to content

Commit bc25e0a

Browse files
committed
Refresh app elicitation for SDK 2 and extension-only specification
1 parent b7bd89f commit bc25e0a

4 files changed

Lines changed: 138 additions & 27 deletions

File tree

‎specification/draft/apps.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,6 +2315,12 @@ extension, the standard MCP elicitation request and result, and the existing
23152315
View↔Host JSON-RPC channel. It defines no new method, result, or extension
23162316
identifier.
23172317
2318+
This feature is specified entirely by MCP Apps. It uses core capability
2319+
extension points and request metadata without changing the core MCP protocol.
2320+
The MCP Apps bridge version is negotiated independently of the core version;
2321+
app rendering also works with initialized sessions that deliver elicitations as
2322+
direct server-to-client requests.
2323+
23182324
#### Associating an App with an Elicitation
23192325
23202326
The server attaches the app resource URI at `_meta.ui.resourceUri` on the

‎src/app-bridge.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,91 @@ describe("App <-> AppBridge integration", () => {
233233
});
234234
});
235235

236+
it.each(["decline", "cancel"] as const)(
237+
"preserves the user's %s result",
238+
async (action) => {
239+
bridge = new AppBridge(createMockClient() as Client, testHostInfo, {
240+
...testHostCapabilities,
241+
elicitation: {},
242+
});
243+
app.onelicitation = async () => ({ action });
244+
await bridge.connect(bridgeTransport);
245+
await app.connect(appTransport);
246+
await expect(
247+
bridge.requestElicitation({
248+
message: "Choose",
249+
requestedSchema: { type: "object", properties: {} },
250+
}),
251+
).resolves.toEqual({ action });
252+
},
253+
);
254+
255+
it("keeps metadata and cancellation bound to the bridge request", async () => {
256+
bridge = new AppBridge(createMockClient() as Client, testHostInfo, {
257+
...testHostCapabilities,
258+
elicitation: {},
259+
});
260+
const controller = new AbortController();
261+
let received!: () => void;
262+
const started = new Promise<void>((resolve) => {
263+
received = resolve;
264+
});
265+
let cancelled!: () => void;
266+
const aborted = new Promise<void>((resolve) => {
267+
cancelled = resolve;
268+
});
269+
app.onelicitation = async (params, ctx) => {
270+
expect(params._meta?.ui).toEqual({
271+
resourceUri: "ui://delivery/choose.html",
272+
});
273+
ctx.mcpReq.signal.addEventListener("abort", cancelled, { once: true });
274+
received();
275+
await aborted;
276+
return { action: "cancel" };
277+
};
278+
await bridge.connect(bridgeTransport);
279+
await app.connect(appTransport);
280+
// SDK 2.0.0 ignores cancellation for request ID 0. Use a nonzero
281+
// request ID here to test propagation independently of that SDK bug.
282+
await bridge.request({ method: "ping" });
283+
const pending = bridge.requestElicitation(
284+
{
285+
message: "Choose",
286+
requestedSchema: { type: "object", properties: {} },
287+
_meta: { ui: { resourceUri: "ui://delivery/choose.html" } },
288+
},
289+
{ signal: controller.signal },
290+
);
291+
const rejection = pending.then(
292+
() => {
293+
throw new Error("Expected cancellation");
294+
},
295+
() => {},
296+
);
297+
await started;
298+
controller.abort("test cancellation");
299+
await rejection;
300+
await aborted;
301+
});
302+
303+
it("rejects URL-mode requests before forwarding them", async () => {
304+
bridge = new AppBridge(createMockClient() as Client, testHostInfo, {
305+
...testHostCapabilities,
306+
elicitation: {},
307+
});
308+
app.onelicitation = async () => ({ action: "cancel" });
309+
await bridge.connect(bridgeTransport);
310+
await app.connect(appTransport);
311+
expect(() =>
312+
bridge.requestElicitation({
313+
mode: "url",
314+
message: "Open",
315+
elicitationId: "test",
316+
url: "https://example.com",
317+
}),
318+
).toThrow("form-mode");
319+
});
320+
236321
it("fails closed when the app did not advertise elicitation", async () => {
237322
bridge = new AppBridge(createMockClient() as Client, testHostInfo, {
238323
...testHostCapabilities,

‎src/app-bridge.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -583,8 +583,13 @@ export class AppBridge extends Protocol<BaseContext> {
583583
* @internal
584584
*/
585585
protected assertCapabilityForMethod(method: string): void {
586-
if (method === "elicitation/create" && !this._appCapabilities?.elicitation) {
587-
throw new Error(`App does not support elicitation capability (required for ${method})`);
586+
if (
587+
method === "elicitation/create" &&
588+
!this._appCapabilities?.elicitation
589+
) {
590+
throw new Error(
591+
`App does not support elicitation capability (required for ${method})`,
592+
);
588593
}
589594
}
590595

@@ -634,7 +639,10 @@ export class AppBridge extends Protocol<BaseContext> {
634639
*
635640
* Hosts should call this only from the core MCP client's handler for the
636641
* originating `elicitation/create` request. The returned value is the
637-
* standard MCP `ElicitResult` and can be returned directly to the server.
642+
* standard MCP `ElicitResult`. Before returning an accepted result to the
643+
* server, hosts must validate its content against `params.requestedSchema`.
644+
* Invalid results should fall back to native form rendering; explicit
645+
* `decline` and `cancel` results must be preserved.
638646
*/
639647
requestElicitation(
640648
params: ElicitRequest["params"],
@@ -654,8 +662,8 @@ export class AppBridge extends Protocol<BaseContext> {
654662
}
655663
return this.request(
656664
{ method: "elicitation/create", params },
665+
ElicitResultSchema,
657666
options,
658-
).then((result) => ElicitResultSchema.parse(result)
659667
);
660668
}
661669

‎src/app.ts‎

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
type CreateMessageRequest,
99
type CreateMessageResult,
1010
type CreateMessageResultWithTools,
11+
type ElicitRequest,
12+
type ElicitResult,
1113
type Implementation,
1214
type ListResourcesRequest,
1315
type ListResourcesResult,
@@ -21,7 +23,11 @@ import {
2123
type ToolListChangedNotification,
2224
type Transport,
2325
} from "@modelcontextprotocol/client";
24-
import { ElicitRequestParamsSchema, ElicitResultSchema, EmptyResultSchema } from "@modelcontextprotocol/core";
26+
import {
27+
ElicitRequestParamsSchema,
28+
ElicitResultSchema,
29+
EmptyResultSchema,
30+
} from "@modelcontextprotocol/core";
2531
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js";
2632
import { EventDispatcher, MethodRegistry } from "./events.js";
2733
import {
@@ -34,7 +40,7 @@ export type {
3440
LegacyNotificationHandler,
3541
LegacyNotificationHandlerSetter,
3642
LegacyRequestHandler,
37-
LegacyBaseContext,
43+
LegacyRequestHandlerExtra,
3844
LegacyRequestHandlerSetter,
3945
} from "./legacy-handlers.js";
4046
export { EventDispatcher } from "./events.js";
@@ -198,10 +204,10 @@ export type AppToolCallback<
198204
> = In extends StandardSchemaV1
199205
? (
200206
args: StandardSchemaV1.InferOutput<In>,
201-
extra: BaseContext,
207+
extra: RequestHandlerExtra,
202208
) => AppToolResult<Out> | Promise<AppToolResult<Out>>
203209
: (
204-
extra: BaseContext,
210+
extra: RequestHandlerExtra,
205211
) => AppToolResult<Out> | Promise<AppToolResult<Out>>;
206212

207213
/**
@@ -224,7 +230,7 @@ export type RegisteredAppTool = {
224230
/** @internal */
225231
handler: (
226232
args: unknown,
227-
extra: BaseContext,
233+
extra: RequestHandlerExtra,
228234
) => Promise<CallToolResult>;
229235
};
230236

@@ -1072,7 +1078,7 @@ export class App extends Protocol<BaseContext> {
10721078
*/
10731079
private _onteardown?: (
10741080
params: McpUiResourceTeardownRequest["params"],
1075-
extra: BaseContext,
1081+
extra: RequestHandlerExtra,
10761082
) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>;
10771083
get onteardown() {
10781084
return this._onteardown;
@@ -1081,7 +1087,7 @@ export class App extends Protocol<BaseContext> {
10811087
callback:
10821088
| ((
10831089
params: McpUiResourceTeardownRequest["params"],
1084-
extra: BaseContext,
1090+
extra: RequestHandlerExtra,
10851091
) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>)
10861092
| undefined,
10871093
) {
@@ -1110,7 +1116,7 @@ export class App extends Protocol<BaseContext> {
11101116
*/
11111117
private _onelicitation?: (
11121118
params: ElicitRequest["params"],
1113-
extra: BaseContext,
1119+
extra: RequestHandlerExtra,
11141120
) => ElicitResult | Promise<ElicitResult>;
11151121
get onelicitation() {
11161122
return this._onelicitation;
@@ -1119,7 +1125,7 @@ export class App extends Protocol<BaseContext> {
11191125
callback:
11201126
| ((
11211127
params: ElicitRequest["params"],
1122-
extra: BaseContext,
1128+
extra: RequestHandlerExtra,
11231129
) => ElicitResult | Promise<ElicitResult>)
11241130
| undefined,
11251131
) {
@@ -1132,15 +1138,19 @@ export class App extends Protocol<BaseContext> {
11321138
if (callback && !this._appCapabilities.elicitation && !this.transport) {
11331139
this._appCapabilities.elicitation = {};
11341140
}
1135-
this.replaceRequestHandler("elicitation/create", { params: ElicitRequestParamsSchema, result: ElicitResultSchema }, (params, extra) => {
1136-
if (!this._onelicitation) {
1137-
throw new Error("No onelicitation handler set");
1138-
}
1139-
if (params.mode !== undefined && params.mode !== "form") {
1140-
throw new Error("MCP Apps only support form-mode elicitations");
1141-
}
1142-
return this._onelicitation(params, extra);
1143-
});
1141+
this.replaceRequestHandler(
1142+
"elicitation/create",
1143+
{ params: ElicitRequestParamsSchema, result: ElicitResultSchema },
1144+
(params, extra) => {
1145+
if (!this._onelicitation) {
1146+
throw new Error("No onelicitation handler set");
1147+
}
1148+
if (params.mode !== undefined && params.mode !== "form") {
1149+
throw new Error("MCP Apps only support form-mode elicitations");
1150+
}
1151+
return this._onelicitation(params, extra);
1152+
},
1153+
);
11441154
}
11451155

11461156
/**
@@ -1173,7 +1183,7 @@ export class App extends Protocol<BaseContext> {
11731183
*/
11741184
private _oncalltool?: (
11751185
params: CallToolRequest["params"],
1176-
extra: BaseContext,
1186+
extra: RequestHandlerExtra,
11771187
) => Promise<CallToolResult>;
11781188
get oncalltool() {
11791189
return this._oncalltool;
@@ -1182,7 +1192,7 @@ export class App extends Protocol<BaseContext> {
11821192
callback:
11831193
| ((
11841194
params: CallToolRequest["params"],
1185-
extra: BaseContext,
1195+
extra: RequestHandlerExtra,
11861196
) => Promise<CallToolResult>)
11871197
| undefined,
11881198
) {
@@ -1240,7 +1250,7 @@ export class App extends Protocol<BaseContext> {
12401250
*/
12411251
private _onlisttools?: (
12421252
params: ListToolsRequest["params"],
1243-
extra: BaseContext,
1253+
extra: RequestHandlerExtra,
12441254
) => Promise<ListToolsResult>;
12451255
get onlisttools() {
12461256
return this._onlisttools;
@@ -1249,7 +1259,7 @@ export class App extends Protocol<BaseContext> {
12491259
callback:
12501260
| ((
12511261
params: ListToolsRequest["params"],
1252-
extra: BaseContext,
1262+
extra: RequestHandlerExtra,
12531263
) => Promise<ListToolsResult>)
12541264
| undefined,
12551265
) {
@@ -1297,7 +1307,9 @@ export class App extends Protocol<BaseContext> {
12971307
return;
12981308
case "elicitation/create":
12991309
if (!this._appCapabilities.elicitation) {
1300-
throw new Error(`App does not support elicitation capability (required for ${method})`);
1310+
throw new Error(
1311+
`App does not support elicitation capability (required for ${method})`,
1312+
);
13011313
}
13021314
return;
13031315
default:

0 commit comments

Comments
 (0)