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
2 changed files with 3532 additions and 226 deletions

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-constant-binary-expression */
/* eslint-disable @typescript-eslint/no-unused-expressions */
import { Client } from './index.js';
import { Client, getSupportedElicitationModes } from './index.js';
import { z } from 'zod';
import {
RequestSchema,
@ -596,6 +596,316 @@ test('should allow setRequestHandler for declared elicitation capability', () =>
}).toThrow('Client does not support sampling capability');
});
test('should accept form-mode elicitation request when client advertises empty elicitation object (back-compat)', 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: {}
}
}
);
// Set up client handler for form-mode elicitation
client.setRequestHandler(ElicitRequestSchema, request => {
expect(request.params.mode).toBe('form');
return {
action: 'accept',
content: {
username: 'test-user',
confirmed: true
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Server should be able to send form-mode elicitation request
// This works because getSupportedElicitationModes defaults to form mode
// when neither form nor url are explicitly declared
const result = await 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']
}
});
expect(result.action).toBe('accept');
expect(result.content).toEqual({
username: 'test-user',
confirmed: true
});
});
test('should reject form-mode elicitation when client only supports URL mode', async () => {
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
capabilities: {
elicitation: {
url: {}
}
}
}
);
const handler = vi.fn().mockResolvedValue({
action: 'cancel'
});
client.setRequestHandler(ElicitRequestSchema, handler);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
let resolveResponse: ((message: unknown) => void) | undefined;
const responsePromise = new Promise<unknown>(resolve => {
resolveResponse = resolve;
});
serverTransport.onmessage = async message => {
if ('method' in message) {
if (message.method === 'initialize') {
if (!('id' in message) || message.id === undefined) {
throw new Error('Expected initialize request to include an id');
}
const messageId = message.id;
await serverTransport.send({
jsonrpc: '2.0',
id: messageId,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}
});
} else if (message.method === 'notifications/initialized') {
// ignore
}
} else {
resolveResponse?.(message);
}
};
await client.connect(clientTransport);
// Server shouldn't send this, because the client capabilities
// only advertised URL mode. Test that it's rejected by the client:
const requestId = 1;
await serverTransport.send({
jsonrpc: '2.0',
id: requestId,
method: 'elicitation/create',
params: {
mode: 'form',
message: 'Provide your username',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string'
}
}
}
}
});
const response = (await responsePromise) as { id: number; error: { code: number; message: string } };
expect(response.id).toBe(requestId);
expect(response.error.code).toBe(ErrorCode.InvalidParams);
expect(response.error.message).toContain('Client does not support form-mode elicitation requests');
expect(handler).not.toHaveBeenCalled();
await client.close();
});
test('should reject URL-mode elicitation when client only supports form mode', async () => {
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
capabilities: {
elicitation: {
form: {}
}
}
}
);
const handler = vi.fn().mockResolvedValue({
action: 'cancel'
});
client.setRequestHandler(ElicitRequestSchema, handler);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
let resolveResponse: ((message: unknown) => void) | undefined;
const responsePromise = new Promise<unknown>(resolve => {
resolveResponse = resolve;
});
serverTransport.onmessage = async message => {
if ('method' in message) {
if (message.method === 'initialize') {
if (!('id' in message) || message.id === undefined) {
throw new Error('Expected initialize request to include an id');
}
const messageId = message.id;
await serverTransport.send({
jsonrpc: '2.0',
id: messageId,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: {
name: 'test-server',
version: '1.0.0'
}
}
});
} else if (message.method === 'notifications/initialized') {
// ignore
}
} else {
resolveResponse?.(message);
}
};
await client.connect(clientTransport);
// Server shouldn't send this, because the client capabilities
// only advertised form mode. Test that it's rejected by the client:
const requestId = 2;
await serverTransport.send({
jsonrpc: '2.0',
id: requestId,
method: 'elicitation/create',
params: {
mode: 'url',
message: 'Open the authorization page',
elicitationId: 'elicitation-123',
url: 'https://example.com/authorize'
}
});
const response = (await responsePromise) as { id: number; error: { code: number; message: string } };
expect(response.id).toBe(requestId);
expect(response.error.code).toBe(ErrorCode.InvalidParams);
expect(response.error.message).toContain('Client does not support URL-mode elicitation requests');
expect(handler).not.toHaveBeenCalled();
await client.close();
});
test('should apply defaults for form-mode elicitation when applyDefaults is enabled', 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
}
}
}
}
);
client.setRequestHandler(ElicitRequestSchema, request => {
expect(request.params.mode).toBe('form');
return {
action: 'accept',
content: {}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const result = await server.elicitInput({
mode: 'form',
message: 'Please confirm your preferences',
requestedSchema: {
type: 'object',
properties: {
confirmed: {
type: 'boolean',
default: true
}
}
}
});
expect(result.action).toBe('accept');
expect(result.content).toEqual({
confirmed: true
});
await client.close();
});
/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
@ -1236,3 +1546,41 @@ describe('outputSchema validation', () => {
);
});
});
describe('getSupportedElicitationModes', () => {
test('should support nothing when capabilities are undefined', () => {
const result = getSupportedElicitationModes(undefined);
expect(result.supportsFormMode).toBe(false);
expect(result.supportsUrlMode).toBe(false);
});
test('should default to form mode when capabilities are an empty object', () => {
const result = getSupportedElicitationModes({});
expect(result.supportsFormMode).toBe(true);
expect(result.supportsUrlMode).toBe(false);
});
test('should support form mode when form is explicitly declared', () => {
const result = getSupportedElicitationModes({ form: {} });
expect(result.supportsFormMode).toBe(true);
expect(result.supportsUrlMode).toBe(false);
});
test('should support url mode when url is explicitly declared', () => {
const result = getSupportedElicitationModes({ url: {} });
expect(result.supportsFormMode).toBe(false);
expect(result.supportsUrlMode).toBe(true);
});
test('should support both modes when both are explicitly declared', () => {
const result = getSupportedElicitationModes({ form: {}, url: {} });
expect(result.supportsFormMode).toBe(true);
expect(result.supportsUrlMode).toBe(true);
});
test('should support form mode when form declares applyDefaults', () => {
const result = getSupportedElicitationModes({ form: { applyDefaults: true } });
expect(result.supportsFormMode).toBe(true);
expect(result.supportsUrlMode).toBe(false);
});
});

View file

@ -85,6 +85,34 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn
}
}
/**
* Determines which elicitation modes are supported based on declared client capabilities.
*
* According to the spec:
* - An empty elicitation capability object defaults to form mode support (backwards compatibility)
* - URL mode is only supported if explicitly declared
*
* @param capabilities - The client's elicitation capabilities
* @returns An object indicating which modes are supported
*/
export function getSupportedElicitationModes(capabilities: ClientCapabilities['elicitation']): {
supportsFormMode: boolean;
supportsUrlMode: boolean;
} {
if (!capabilities) {
return { supportsFormMode: false, supportsUrlMode: false };
}
const hasFormCapability = capabilities.form !== undefined;
const hasUrlCapability = capabilities.url !== undefined;
// If neither form nor url are explicitly declared, form mode is supported (backwards compatibility)
const supportsFormMode = hasFormCapability || (!hasFormCapability && !hasUrlCapability);
const supportsUrlMode = hasUrlCapability;
return { supportsFormMode, supportsUrlMode };
}
export type ClientOptions = ProtocolOptions & {
/**
* Capabilities to advertise as being supported by this client.
@ -210,6 +238,17 @@ export class Client<
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${validatedRequest.error.message}`);
}
const { params } = validatedRequest.data;
const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation);
if (params.mode === 'form' && !supportsFormMode) {
throw new McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests');
}
if (params.mode === 'url' && !supportsUrlMode) {
throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests');
}
const result = await Promise.resolve(handler(request, extra));
const validationResult = ElicitResultSchema.safeParse(result);
@ -218,17 +257,15 @@ export class Client<
}
const validatedResult = validationResult.data;
const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined;
if (
this._capabilities.elicitation?.applyDefaults &&
validatedResult.action === 'accept' &&
validatedResult.content &&
validatedRequest.data.params.requestedSchema
) {
try {
applyElicitationDefaults(validatedRequest.data.params.requestedSchema, validatedResult.content);
} catch {
// gracefully ignore errors in default application
if (params.mode === 'form' && validatedResult.action === 'accept' && validatedResult.content && requestedSchema) {
if (this._capabilities.elicitation?.form?.applyDefaults) {
try {
applyElicitationDefaults(requestedSchema, validatedResult.content);
} catch {
// gracefully ignore errors in default application
}
}
}