Skip to content

Commit c6fc36c

Browse files
committed
SEP-1036: URL Elicitation
1 parent 29cb080 commit c6fc36c

File tree

5 files changed

+173
-36
lines changed

5 files changed

+173
-36
lines changed

src/client/index.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
22
import type { Transport } from '../shared/transport.js';
3+
import { getSupportedElicitationModes } from '../shared/elicitation-utils.js';
34
import {
45
type CallToolRequest,
56
CallToolResultSchema,
@@ -210,6 +211,17 @@ export class Client<
210211
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${validatedRequest.error.message}`);
211212
}
212213

214+
const { params } = validatedRequest.data;
215+
const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation);
216+
217+
if (params.mode === 'form' && !supportsFormMode) {
218+
throw new McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests');
219+
}
220+
221+
if (params.mode === 'url' && !supportsUrlMode) {
222+
throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests');
223+
}
224+
213225
const result = await Promise.resolve(handler(request, extra));
214226

215227
const validationResult = ElicitResultSchema.safeParse(result);
@@ -218,17 +230,16 @@ export class Client<
218230
}
219231

220232
const validatedResult = validationResult.data;
221-
222-
if (
223-
this._capabilities.elicitation?.applyDefaults &&
224-
validatedResult.action === 'accept' &&
225-
validatedResult.content &&
226-
validatedRequest.data.params.requestedSchema
227-
) {
228-
try {
229-
applyElicitationDefaults(validatedRequest.data.params.requestedSchema, validatedResult.content);
230-
} catch {
231-
// gracefully ignore errors in default application
233+
const requestedSchema =
234+
params.mode === 'form' ? (params.requestedSchema as unknown as JsonSchemaType | undefined) : undefined;
235+
236+
if (params.mode === 'form' && validatedResult.action === 'accept' && validatedResult.content && requestedSchema) {
237+
if (this._capabilities.elicitation?.applyDefaults) {
238+
try {
239+
applyElicitationDefaults(requestedSchema, validatedResult.content);
240+
} catch {
241+
// gracefully ignore errors in default application
242+
}
232243
}
233244
}
234245

src/server/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ export class Server<
225225
}
226226
break;
227227

228+
case 'notifications/elicitation/complete':
229+
if (!this._clientCapabilities?.elicitation?.url) {
230+
throw new Error(`Client does not support URL elicitation (required for ${method})`);
231+
}
232+
break;
233+
228234
case 'notifications/cancelled':
229235
// Cancellation notifications are always allowed
230236
break;
@@ -321,10 +327,14 @@ export class Server<
321327
}
322328

323329
async elicitInput(params: ElicitRequest['params'], options?: RequestOptions): Promise<ElicitResult> {
330+
const mode = params.mode;
331+
if (!this._clientCapabilities?.elicitation?.[mode]) {
332+
throw new Error(`Client does not support ${mode} elicitation.`);
333+
}
324334
const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options);
325335

326-
// Validate the response content against the requested schema if action is "accept"
327-
if (result.action === 'accept' && result.content && params.requestedSchema) {
336+
// If this is a form payload, validate the response content against the requested schema if action is "accept"
337+
if (mode === 'form' && result.action === 'accept' && result.content && params.requestedSchema) {
328338
try {
329339
const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType);
330340
const validationResult = validator(result.content);

src/server/mcp.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ export class McpServer {
178178
}
179179
}
180180
} catch (error) {
181+
if (error instanceof McpError) {
182+
if (error.code === ErrorCode.UrlElicitationRequired) {
183+
throw error; // Return the error to the caller without wrapping in CallToolResult
184+
}
185+
}
181186
return this.createToolError(error instanceof Error ? error.message : String(error));
182187
}
183188

src/shared/protocol.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
250250
const totalElapsed = Date.now() - info.startTime;
251251
if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) {
252252
this._timeoutInfo.delete(messageId);
253-
throw new McpError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
253+
throw McpError.fromError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
254254
maxTotalTimeout: info.maxTotalTimeout,
255255
totalElapsed
256256
});
@@ -313,7 +313,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
313313
this._transport = undefined;
314314
this.onclose?.();
315315

316-
const error = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
316+
const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed');
317317
for (const handler of responseHandlers.values()) {
318318
handler(error);
319319
}
@@ -396,7 +396,8 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
396396
id: request.id,
397397
error: {
398398
code: Number.isSafeInteger(error['code']) ? error['code'] : ErrorCode.InternalError,
399-
message: error.message ?? 'Internal error'
399+
message: error.message ?? 'Internal error',
400+
...(error['data'] !== undefined && { data: error['data'] })
400401
}
401402
});
402403
}
@@ -447,7 +448,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
447448
if (isJSONRPCResponse(response)) {
448449
handler(response);
449450
} else {
450-
const error = new McpError(response.error.code, response.error.message, response.error.data);
451+
const error = McpError.fromError(response.error.code, response.error.message, response.error.data);
451452
handler(error);
452453
}
453454
}
@@ -566,7 +567,7 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
566567
});
567568

568569
const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
569-
const timeoutHandler = () => cancel(new McpError(ErrorCode.RequestTimeout, 'Request timed out', { timeout }));
570+
const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, 'Request timed out', { timeout }));
570571

571572
this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false);
572573

src/types.ts

Lines changed: 128 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ export enum ErrorCode {
137137
InvalidRequest = -32600,
138138
MethodNotFound = -32601,
139139
InvalidParams = -32602,
140-
InternalError = -32603
140+
InternalError = -32603,
141+
142+
// MCP-specific error codes
143+
UrlElicitationRequired = -32042
141144
}
142145

143146
/**
@@ -271,6 +274,28 @@ export const ImplementationSchema = BaseMetadataSchema.extend({
271274
websiteUrl: z.string().optional()
272275
}).merge(IconsSchema);
273276

277+
const ElicitationCapabilitySchema = z
278+
.preprocess(
279+
value => {
280+
if (value && typeof value === 'object' && !Array.isArray(value)) {
281+
const hasForm = Object.prototype.hasOwnProperty.call(value, 'form');
282+
const hasUrl = Object.prototype.hasOwnProperty.call(value, 'url');
283+
if (!hasForm && !hasUrl) {
284+
return { ...(value as Record<string, unknown>), form: {} };
285+
}
286+
}
287+
return value;
288+
},
289+
z
290+
.object({
291+
applyDefaults: z.boolean().optional(),
292+
form: z.object({}).passthrough().optional(),
293+
url: z.object({}).passthrough().optional()
294+
})
295+
.passthrough()
296+
)
297+
.optional();
298+
274299
/**
275300
* Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.
276301
*/
@@ -286,17 +311,7 @@ export const ClientCapabilitiesSchema = z.object({
286311
/**
287312
* Present if the client supports eliciting user input.
288313
*/
289-
elicitation: z.intersection(
290-
z
291-
.object({
292-
/**
293-
* Whether the client should apply defaults to the user input.
294-
*/
295-
applyDefaults: z.boolean().optional()
296-
})
297-
.optional(),
298-
z.record(z.string(), z.unknown()).optional()
299-
),
314+
elicitation: ElicitationCapabilitySchema,
300315
/**
301316
* Present if the client supports listing roots.
302317
*/
@@ -1337,9 +1352,25 @@ export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, Boolea
13371352
*/
13381353
export const ElicitRequestParamsSchema = BaseRequestParamsSchema.extend({
13391354
/**
1340-
* The message to present to the user.
1355+
* The mode of elicitation.
1356+
* - "form": In-band structured data collection with optional schema validation
1357+
* - "url": Out-of-band interaction via URL navigation
1358+
*/
1359+
mode: z.enum(['form', 'url']),
1360+
/**
1361+
* The progress token as specified in the Progress capability, required in this request.
1362+
*/
1363+
message: z.string()
1364+
});
1365+
1366+
/**
1367+
* Parameters for form-based elicitation.
1368+
*/
1369+
export const ElicitRequestFormParamsSchema = ElicitRequestParamsSchema.extend({
1370+
/**
1371+
* The elicitation mode.
13411372
*/
1342-
message: z.string(),
1373+
mode: z.literal('form'),
13431374
/**
13441375
* A restricted subset of JSON Schema.
13451376
* Only top-level properties are allowed, without nesting.
@@ -1351,13 +1382,55 @@ export const ElicitRequestParamsSchema = BaseRequestParamsSchema.extend({
13511382
})
13521383
});
13531384

1385+
/**
1386+
* Parameters for URL-based elicitation.
1387+
*/
1388+
export const ElicitRequestURLParamsSchema = ElicitRequestParamsSchema.extend({
1389+
/**
1390+
* The elicitation mode.
1391+
*/
1392+
mode: z.literal('url'),
1393+
/**
1394+
* The ID of the elicitation, which must be unique within the context of the server.
1395+
* The client MUST treat this ID as an opaque value.
1396+
*/
1397+
elicitationId: z.string(),
1398+
/**
1399+
* The URL that the user should navigate to.
1400+
*/
1401+
url: z.string().url()
1402+
});
1403+
13541404
/**
13551405
* A request from the server to elicit user input via the client.
1356-
* The client should present the message and form fields to the user.
1406+
* The client should present the message and form fields to the user (form mode)
1407+
* or navigate to a URL (URL mode).
13571408
*/
13581409
export const ElicitRequestSchema = RequestSchema.extend({
13591410
method: z.literal('elicitation/create'),
1360-
params: ElicitRequestParamsSchema
1411+
params: z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema])
1412+
});
1413+
1414+
/**
1415+
* Parameters for a `notifications/elicitation/complete` notification.
1416+
*
1417+
* @category notifications/elicitation/complete
1418+
*/
1419+
export const ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({
1420+
/**
1421+
* The ID of the elicitation that completed.
1422+
*/
1423+
elicitationId: z.string()
1424+
});
1425+
1426+
/**
1427+
* A notification from the server to the client, informing it of a completion of an out-of-band elicitation request.
1428+
*
1429+
* @category notifications/elicitation/complete
1430+
*/
1431+
export const ElicitationCompleteNotificationSchema = NotificationSchema.extend({
1432+
method: z.literal('notifications/elicitation/complete'),
1433+
params: ElicitationCompleteNotificationParamsSchema
13611434
});
13621435

13631436
/**
@@ -1553,7 +1626,8 @@ export const ServerNotificationSchema = z.union([
15531626
ResourceUpdatedNotificationSchema,
15541627
ResourceListChangedNotificationSchema,
15551628
ToolListChangedNotificationSchema,
1556-
PromptListChangedNotificationSchema
1629+
PromptListChangedNotificationSchema,
1630+
ElicitationCompleteNotificationSchema
15571631
]);
15581632

15591633
export const ServerResultSchema = z.union([
@@ -1578,6 +1652,38 @@ export class McpError extends Error {
15781652
super(`MCP error ${code}: ${message}`);
15791653
this.name = 'McpError';
15801654
}
1655+
1656+
/**
1657+
* Factory method to create the appropriate error type based on the error code and data
1658+
*/
1659+
static fromError(code: number, message: string, data?: unknown): McpError {
1660+
// Check for specific error types
1661+
if (code === ErrorCode.UrlElicitationRequired && data) {
1662+
const errorData = data as { elicitations?: unknown[] };
1663+
if (errorData.elicitations) {
1664+
return new UrlElicitationRequiredError(errorData.elicitations as ElicitRequestURLParams[], message);
1665+
}
1666+
}
1667+
1668+
// Default to generic McpError
1669+
return new McpError(code, message, data);
1670+
}
1671+
}
1672+
1673+
/**
1674+
* Specialized error type when a tool requires a URL mode elicitation.
1675+
* This makes it nicer for the client to handle since there is specific data to work with instead of just a code to check against.
1676+
*/
1677+
export class UrlElicitationRequiredError extends McpError {
1678+
constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) {
1679+
super(ErrorCode.UrlElicitationRequired, message, {
1680+
elicitations: elicitations
1681+
});
1682+
}
1683+
1684+
get elicitations(): ElicitRequestURLParams[] {
1685+
return (this.data as { elicitations: ElicitRequestURLParams[] })?.elicitations ?? [];
1686+
}
15811687
}
15821688

15831689
type Primitive = string | number | boolean | bigint | null | undefined;
@@ -1755,8 +1861,12 @@ export type SingleSelectEnumSchema = Infer<typeof SingleSelectEnumSchemaSchema>;
17551861
export type MultiSelectEnumSchema = Infer<typeof MultiSelectEnumSchemaSchema>;
17561862

17571863
export type PrimitiveSchemaDefinition = Infer<typeof PrimitiveSchemaDefinitionSchema>;
1758-
export type ElicitRequestParams = Infer<typeof ElicitRequestParamsSchema>;
1864+
//export type ElicitRequestParams = Infer<typeof ElicitRequestParamsSchema>; // TODO: remove this
1865+
export type ElicitRequestFormParams = Infer<typeof ElicitRequestFormParamsSchema>;
1866+
export type ElicitRequestURLParams = Infer<typeof ElicitRequestURLParamsSchema>;
17591867
export type ElicitRequest = Infer<typeof ElicitRequestSchema>;
1868+
export type ElicitationCompleteNotificationParams = Infer<typeof ElicitationCompleteNotificationParamsSchema>;
1869+
export type ElicitationCompleteNotification = Infer<typeof ElicitationCompleteNotificationSchema>;
17601870
export type ElicitResult = Infer<typeof ElicitResultSchema>;
17611871

17621872
/* Autocomplete */

0 commit comments

Comments
 (0)