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