SEP-1036: URL Elicitation (#1105)
This commit is contained in:
parent
df56e35322
commit
4debc740a5
2 changed files with 3532 additions and 226 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue