SEP-1036: URL Elicitation (#1105)

This commit is contained in:
Nate Barbettini 2025-11-18 02:53:15 -08:00 • committed by GitHub
commit 4debc740a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 3532 additions and 226 deletions

View file

@ -9,7 +9,7 @@
import { Client } from '../client/index.js';
import { InMemoryTransport } from '../inMemory.js';
import { ElicitRequestParams, ElicitRequestSchema } from '../types.js';
import { ElicitRequestFormParams, ElicitRequestSchema } from '../types.js';
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js';
import { Server } from './index.js';
@ -70,6 +70,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'What is your name?',
requestedSchema: {
type: 'object',
@ -93,6 +94,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'What is your age?',
requestedSchema: {
type: 'object',
@ -116,6 +118,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'Do you agree?',
requestedSchema: {
type: 'object',
@ -149,7 +152,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
content: userData
}));
const result = await server.elicitInput({
const formRequestParams: ElicitRequestFormParams = {
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -166,7 +170,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
},
required: ['name', 'email', 'age', 'street', 'city', 'zipCode']
}
});
};
const result = await server.elicitInput(formRequestParams);
expect(result).toEqual({
action: 'accept',
@ -185,6 +190,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -209,6 +215,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -250,6 +257,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
await expect(
server.elicitInput({
mode: 'form',
message: 'What is your age?',
requestedSchema: {
type: 'object',
@ -268,19 +276,20 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
content: { zipCode: 'ABC123' } // Doesn't match pattern
}));
await expect(
server.elicitInput({
message: 'Enter a 5-digit zip code',
requestedSchema: {
type: 'object',
properties: {
// @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator
zipCode: { type: 'string', pattern: '^[0-9]{5}$' }
},
required: ['zipCode']
}
})
).rejects.toThrow(/does not match requested schema/);
const formRequestParams: ElicitRequestFormParams = {
mode: 'form',
message: 'Enter a 5-digit zip code',
requestedSchema: {
type: 'object',
properties: {
// @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator
zipCode: { type: 'string', pattern: '^[0-9]{5}$' }
},
required: ['zipCode']
}
};
await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/);
});
test(`${validatorName}: should allow decline action without validation`, async () => {
@ -289,6 +298,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -310,6 +320,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -340,6 +351,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
});
const nameResult = await server.elicitInput({
mode: 'form',
message: 'What is your name?',
requestedSchema: {
type: 'object',
@ -385,6 +397,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'Enter your name',
requestedSchema: {
type: 'object',
@ -409,6 +422,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'Enter your name',
requestedSchema: {
type: 'object',
@ -433,6 +447,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
}));
const result = await server.elicitInput({
mode: 'form',
message: 'Enter your email',
requestedSchema: {
type: 'object',
@ -463,13 +478,15 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
{
capabilities: {
elicitation: {
applyDefaults: true
form: {
applyDefaults: true
}
}
}
}
);
const testSchemaProperties: ElicitRequestParams['requestedSchema'] = {
const testSchemaProperties: ElicitRequestFormParams['requestedSchema'] = {
type: 'object',
properties: {
subscribe: { type: 'boolean', default: true },
@ -542,7 +559,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Client returns no values; SDK should apply defaults automatically (and validate)
client.setRequestHandler(ElicitRequestSchema, request => {
expect(request.params.requestedSchema).toEqual(testSchemaProperties);
expect(request.params.mode).toEqual('form');
expect((request.params as ElicitRequestFormParams).requestedSchema).toEqual(testSchemaProperties);
return {
action: 'accept',
content: {}
@ -553,6 +571,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result = await server.elicitInput({
mode: 'form',
message: 'Provide your preferences',
requestedSchema: testSchemaProperties
});
@ -582,6 +601,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
await expect(
server.elicitInput({
mode: 'form',
message: 'Enter your email',
requestedSchema: {
type: 'object',
@ -608,6 +628,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -643,6 +664,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -720,6 +742,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -759,6 +782,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -803,6 +827,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -900,6 +925,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -934,6 +960,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',

View file

@ -6,6 +6,7 @@ import type { Transport } from '../shared/transport.js';
import {
CreateMessageRequestSchema,
ElicitRequestSchema,
ElicitationCompleteNotificationSchema,
ErrorCode,
LATEST_PROTOCOL_VERSION,
ListPromptsRequestSchema,
@ -19,6 +20,7 @@ import {
SUPPORTED_PROTOCOL_VERSIONS
} from '../types.js';
import { Server } from './index.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
test('should accept latest protocol version', async () => {
let sendPromiseResolve: (value: unknown) => void;
@ -304,7 +306,92 @@ test('should respect client elicitation capabilities', async () => {
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
expect(server.getClientCapabilities()).toEqual({ elicitation: {} });
// After schema parsing, empty elicitation object should have form capability injected
expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } });
// This should work because elicitation is supported by the client
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your username',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
description: 'Your username'
},
confirmed: {
type: 'boolean',
title: 'Confirm',
description: 'Please confirm',
default: false
}
},
required: ['username']
}
})
).resolves.toEqual({
action: 'accept',
content: {
username: 'test-user',
confirmed: true
}
});
// This should still throw because sampling is not supported by the client
await expect(
server.createMessage({
messages: [],
maxTokens: 10
})
).rejects.toThrow(/^Client does not support/);
});
test('should use elicitInput with mode: "form" by default for backwards compatibility', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {}
}
}
);
client.setRequestHandler(ElicitRequestSchema, params => ({
action: 'accept',
content: {
username: params.params.message.includes('username') ? 'test-user' : undefined,
confirmed: true
}
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// After schema parsing, empty elicitation object should have form capability injected
expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } });
// This should work because elicitation is supported by the client
await expect(
@ -345,6 +432,534 @@ test('should respect client elicitation capabilities', async () => {
).rejects.toThrow(/^Client does not support/);
});
test('should throw when elicitInput is called without client form capability', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
url: {} // No form mode capability
}
}
}
);
client.setRequestHandler(ElicitRequestSchema, () => ({
action: 'cancel'
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your username',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string'
}
}
}
})
).rejects.toThrow('Client does not support form elicitation.');
});
test('should throw when elicitInput is called without client URL capability', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
form: {} // No URL mode capability
}
}
}
);
client.setRequestHandler(ElicitRequestSchema, () => ({
action: 'cancel'
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
await expect(
server.elicitInput({
mode: 'url',
message: 'Open the authorization URL',
elicitationId: 'elicitation-001',
url: 'https://example.com/auth'
})
).rejects.toThrow('Client does not support url elicitation.');
});
test('should include form mode when sending elicitation form requests', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
form: {}
}
}
}
);
const receivedModes: string[] = [];
client.setRequestHandler(ElicitRequestSchema, request => {
receivedModes.push(request.params.mode);
return {
action: 'accept',
content: {
confirmation: true
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
await expect(
server.elicitInput({
message: 'Confirm action',
requestedSchema: {
type: 'object',
properties: {
confirmation: {
type: 'boolean'
}
},
required: ['confirmation']
}
})
).resolves.toEqual({
action: 'accept',
content: {
confirmation: true
}
});
expect(receivedModes).toEqual(['form']);
});
test('should include url mode when sending elicitation URL requests', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
url: {}
}
}
}
);
const receivedModes: string[] = [];
const receivedIds: string[] = [];
client.setRequestHandler(ElicitRequestSchema, request => {
receivedModes.push(request.params.mode);
if (request.params.mode === 'url') {
receivedIds.push(request.params.elicitationId);
}
return {
action: 'decline'
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
await expect(
server.elicitInput({
mode: 'url',
message: 'Complete verification',
elicitationId: 'elicitation-xyz',
url: 'https://example.com/verify'
})
).resolves.toEqual({
action: 'decline'
});
expect(receivedModes).toEqual(['url']);
expect(receivedIds).toEqual(['elicitation-xyz']);
});
test('should reject elicitInput when client response violates requested schema', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
form: {}
}
}
}
);
client.setRequestHandler(ElicitRequestSchema, () => ({
action: 'accept',
// Bad response: missing required field `username`
content: {}
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
await expect(
server.elicitInput({
message: 'Please provide your username',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string'
}
},
required: ['username']
}
})
).rejects.toThrow('Elicitation response content does not match requested schema');
});
test('should wrap unexpected validator errors during elicitInput', async () => {
class ThrowingValidator implements jsonSchemaValidator {
getValidator<T>(_schema: JsonSchemaType): JsonSchemaValidator<T> {
throw new Error('boom - validator exploded');
}
}
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {},
jsonSchemaValidator: new ThrowingValidator()
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
form: {}
}
}
}
);
client.setRequestHandler(ElicitRequestSchema, () => ({
action: 'accept',
content: {
username: 'ignored'
}
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
await expect(
server.elicitInput({
mode: 'form',
message: 'Provide any data',
requestedSchema: {
type: 'object',
properties: {},
required: []
}
})
).rejects.toThrow('MCP error -32603: Error validating elicitation response: boom - validator exploded');
});
test('should forward notification options when using elicitation completion notifier', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
url: {}
}
}
}
);
client.setNotificationHandler(ElicitationCompleteNotificationSchema, () => {});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const notificationSpy = vi.spyOn(server, 'notification');
const notifier = server.createElicitationCompletionNotifier('elicitation-789', { relatedRequestId: 42 });
await notifier();
expect(notificationSpy).toHaveBeenCalledWith(
{
method: 'notifications/elicitation/complete',
params: {
elicitationId: 'elicitation-789'
}
},
expect.objectContaining({ relatedRequestId: 42 })
);
});
test('should create notifier that emits elicitation completion notification', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
url: {}
}
}
}
);
const receivedIds: string[] = [];
client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
receivedIds.push(notification.params.elicitationId);
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const notifier = server.createElicitationCompletionNotifier('elicitation-123');
await notifier();
await new Promise(resolve => setTimeout(resolve, 0));
expect(receivedIds).toEqual(['elicitation-123']);
});
test('should throw when creating notifier if client lacks URL elicitation support', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
form: {}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
expect(() => server.createElicitationCompletionNotifier('elicitation-123')).toThrow(
'Client does not support URL elicitation (required for notifications/elicitation/complete)'
);
});
test('should apply back-compat form capability injection when client sends empty elicitation object', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Verify that the schema preprocessing injected form capability
const clientCapabilities = server.getClientCapabilities();
expect(clientCapabilities).toBeDefined();
expect(clientCapabilities?.elicitation).toBeDefined();
expect(clientCapabilities?.elicitation?.form).toBeDefined();
expect(clientCapabilities?.elicitation?.form).toEqual({});
expect(clientCapabilities?.elicitation?.url).toBeUndefined();
});
test('should preserve form capability configuration when client enables applyDefaults', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
form: {
applyDefaults: true
}
}
}
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Verify that the schema preprocessing preserved the form capability configuration
const clientCapabilities = server.getClientCapabilities();
expect(clientCapabilities).toBeDefined();
expect(clientCapabilities?.elicitation).toBeDefined();
expect(clientCapabilities?.elicitation?.form).toBeDefined();
expect(clientCapabilities?.elicitation?.form).toEqual({ applyDefaults: true });
expect(clientCapabilities?.elicitation?.url).toBeUndefined();
});
test('should validate elicitation response against requested schema', async () => {
const server = new Server(
{
@ -391,6 +1006,7 @@ test('should validate elicitation response against requested schema', async () =
// Test with valid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -467,6 +1083,7 @@ test('should reject elicitation response with invalid data', async () => {
// Test with invalid response
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your information',
requestedSchema: {
type: 'object',
@ -545,6 +1162,7 @@ test('should allow elicitation reject and cancel without validation', async () =
// Test reject - should not validate
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your name',
requestedSchema: schema
})
@ -555,6 +1173,7 @@ test('should allow elicitation reject and cancel without validation', async () =
// Test cancel - should not validate
await expect(
server.elicitInput({
mode: 'form',
message: 'Please provide your name',
requestedSchema: schema
})

View file

@ -1,9 +1,10 @@
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
import {
type ClientCapabilities,
type CreateMessageRequest,
CreateMessageResultSchema,
type ElicitRequest,
type ElicitRequestFormParams,
type ElicitRequestURLParams,
type ElicitResult,
ElicitResultSchema,
EmptyResultSchema,
@ -34,6 +35,8 @@ import {
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js';
type LegacyElicitRequestFormParams = Omit<ElicitRequestFormParams, 'mode'>;
export type ServerOptions = ProtocolOptions & {
/**
* Capabilities to advertise as being supported by this server.
@ -225,6 +228,12 @@ export class Server<
}
break;
case 'notifications/elicitation/complete':
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error(`Client does not support URL elicitation (required for ${method})`);
}
break;
case 'notifications/cancelled':
// Cancellation notifications are always allowed
break;
@ -320,33 +329,100 @@ export class Server<
return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options);
}
async elicitInput(params: ElicitRequest['params'], options?: RequestOptions): Promise<ElicitResult> {
const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, options);
/**
* Creates an elicitation request for the given parameters.
* @param params The parameters for the form elicitation request (explicit mode: 'form').
* @param options Optional request options.
* @returns The result of the elicitation request.
*/
async elicitInput(params: ElicitRequestFormParams, options?: RequestOptions): Promise<ElicitResult>;
/**
* Creates an elicitation request for the given parameters.
* @param params The parameters for the URL elicitation request (with url and elicitationId).
* @param options Optional request options.
* @returns The result of the elicitation request.
*/
async elicitInput(params: ElicitRequestURLParams, options?: RequestOptions): Promise<ElicitResult>;
/**
* Creates an elicitation request for the given parameters.
* @param params The parameters for the form elicitation request (legacy signature without mode).
* @param options Optional request options.
* @returns The result of the elicitation request.
*/
async elicitInput(params: LegacyElicitRequestFormParams, options?: RequestOptions): Promise<ElicitResult>;
async elicitInput(
params: LegacyElicitRequestFormParams | ElicitRequestFormParams | ElicitRequestURLParams,
options?: RequestOptions
): Promise<ElicitResult> {
const mode = 'mode' in params ? params.mode : 'form';
// Validate the response content against the requested schema if action is "accept"
if (result.action === 'accept' && result.content && params.requestedSchema) {
try {
const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType);
const validationResult = validator(result.content);
switch (mode) {
case 'url': {
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error('Client does not support url elicitation.');
}
if (!validationResult.valid) {
throw new McpError(
ErrorCode.InvalidParams,
`Elicitation response content does not match requested schema: ${validationResult.errorMessage}`
);
const urlParams = params as ElicitRequestURLParams;
return this.request({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options);
}
case 'form': {
if (!this._clientCapabilities?.elicitation?.form) {
throw new Error('Client does not support form elicitation.');
}
} catch (error) {
if (error instanceof McpError) {
throw error;
const formParams: ElicitRequestFormParams =
'mode' in params ? (params as ElicitRequestFormParams) : { ...(params as LegacyElicitRequestFormParams), mode: 'form' };
const result = await this.request({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options);
if (result.action === 'accept' && result.content && formParams.requestedSchema) {
try {
const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType);
const validationResult = validator(result.content);
if (!validationResult.valid) {
throw new McpError(
ErrorCode.InvalidParams,
`Elicitation response content does not match requested schema: ${validationResult.errorMessage}`
);
}
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}`
);
}
}
throw new McpError(
ErrorCode.InternalError,
`Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}`
);
return result;
}
}
}
return result;
/**
* Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete`
* notification for the specified elicitation ID.
*
* @param elicitationId The ID of the elicitation to mark as complete.
* @param options Optional notification options. Useful when the completion notification should be related to a prior request.
* @returns A function that emits the completion notification when awaited.
*/
createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise<void> {
if (!this._clientCapabilities?.elicitation?.url) {
throw new Error('Client does not support URL elicitation (required for notifications/elicitation/complete)');
}
return () =>
this.notification(
{
method: 'notifications/elicitation/complete',
params: {
elicitationId
}
},
options
);
}
async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) {

View file

@ -15,7 +15,9 @@ import {
LoggingMessageNotificationSchema,
type Notification,
ReadResourceResultSchema,
type TextContent
type TextContent,
UrlElicitationRequiredError,
ErrorCode
} from '../types.js';
import { completable } from './completable.js';
import { McpServer, ResourceTemplate } from './mcp.js';
@ -1618,6 +1620,60 @@ describe('tool()', () => {
);
});
/***
* Test: URL Elicitation Required Error Propagation
*/
test('should propagate UrlElicitationRequiredError to client callers', async () => {
const mcpServer = new McpServer({
name: 'test server',
version: '1.0'
});
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {
url: {}
}
}
}
);
const elicitationParams = {
mode: 'url' as const,
elicitationId: 'elicitation-123',
url: 'https://mcp.example.com/connect',
message: 'Authorization required'
};
mcpServer.tool('needs-authorization', async () => {
throw new UrlElicitationRequiredError([elicitationParams], 'Confirmation required');
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
await client
.callTool({
name: 'needs-authorization'
})
.then(() => {
throw new Error('Expected callTool to throw UrlElicitationRequiredError');
})
.catch(error => {
expect(error).toBeInstanceOf(UrlElicitationRequiredError);
if (error instanceof UrlElicitationRequiredError) {
expect(error.code).toBe(ErrorCode.UrlElicitationRequired);
expect(error.elicitations).toEqual([elicitationParams]);
}
});
});
/***
* Test: Tool Registration with _meta field
*/
@ -4098,6 +4154,7 @@ describe('elicitInput()', () => {
if (!available) {
// Ask user if they want to try alternative dates
const result = await mcpServer.server.elicitInput({
mode: 'form',
message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`,
requestedSchema: {
type: 'object',

View file

@ -178,6 +178,11 @@ export class McpServer {
}
}
} catch (error) {
if (error instanceof McpError) {
if (error.code === ErrorCode.UrlElicitationRequired) {
throw error; // Return the error to the caller without wrapping in CallToolResult
}
}
return this.createToolError(error instanceof Error ? error.message : String(error));
}