diff --git a/src/client/index.ts b/src/client/index.ts index 4dc6a20d9..5770f9d7f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -70,6 +70,19 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn } } } + + if (Array.isArray(schema.anyOf)) { + for (const sub of schema.anyOf) { + applyElicitationDefaults(sub, data); + } + } + + // Combine schemas + if (Array.isArray(schema.oneOf)) { + for (const sub of schema.oneOf) { + applyElicitationDefaults(sub, data); + } + } } export type ClientOptions = ProtocolOptions & { diff --git a/src/server/elicitation.test.ts b/src/server/elicitation.test.ts index 671500bc7..dad56d133 100644 --- a/src/server/elicitation.test.ts +++ b/src/server/elicitation.test.ts @@ -9,7 +9,7 @@ import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; -import { ElicitRequestSchema } from '../types.js'; +import { ElicitRequestParams, ElicitRequestSchema } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js'; import { Server } from './index.js'; @@ -17,36 +17,58 @@ import { Server } from './index.js'; const ajvProvider = new AjvJsonSchemaValidator(); const cfWorkerProvider = new CfWorkerJsonSchemaValidator(); +let server: Server; +let client: Client; + describe('Elicitation Flow', () => { describe('with AJV validator', () => { + beforeEach(async () => { + server = new Server( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: ajvProvider + } + ); + + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + }); + testElicitationFlow(ajvProvider, 'AJV'); }); describe('with CfWorker validator', () => { + beforeEach(async () => { + server = new Server( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: cfWorkerProvider + } + ); + + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + }); + testElicitationFlow(cfWorkerProvider, 'CfWorker'); }); }); function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWorkerProvider, validatorName: string) { test(`${validatorName}: should elicit simple object with string field`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { name: 'John Doe' } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'What is your name?', requestedSchema: { @@ -65,24 +87,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should elicit object with integer field`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { age: 42 } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'What is your age?', requestedSchema: { @@ -101,24 +110,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should elicit object with boolean field`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { agree: true } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Do you agree?', requestedSchema: { @@ -137,16 +133,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should elicit complex object with multiple fields`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - const userData = { name: 'Jane Smith', email: 'jane@example.com', @@ -163,9 +149,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: userData })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Please provide your information', requestedSchema: { @@ -192,16 +175,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should reject invalid object (missing required field)`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { @@ -210,9 +183,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect( server.elicitInput({ message: 'Please provide your information', @@ -229,16 +199,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should reject invalid field type`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { @@ -247,9 +207,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect( server.elicitInput({ message: 'Please provide your information', @@ -266,24 +223,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should reject invalid string (too short)`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { name: '' } // Too short })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect( server.elicitInput({ message: 'What is your name?', @@ -299,24 +243,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should reject invalid integer (out of range)`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { age: 200 } // Too high })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect( server.elicitInput({ message: 'What is your age?', @@ -332,24 +263,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should reject invalid pattern`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { zipCode: 'ABC123' } // Doesn't match pattern })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect( server.elicitInput({ message: 'Enter a 5-digit zip code', @@ -366,23 +284,10 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should allow decline action without validation`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'decline' })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Please provide your information', requestedSchema: { @@ -400,23 +305,10 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should allow cancel action without validation`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'cancel' })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Please provide your information', requestedSchema: { @@ -434,16 +326,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - let requestCount = 0; client.setRequestHandler(ElicitRequestSchema, request => { requestCount++; @@ -457,9 +339,6 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo return { action: 'decline' }; }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const nameResult = await server.elicitInput({ message: 'What is your name?', requestedSchema: { @@ -500,24 +379,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should validate with optional fields present`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { name: 'John', nickname: 'Johnny' } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Enter your name', requestedSchema: { @@ -537,24 +403,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should validate with optional fields absent`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { name: 'John' } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Enter your name', requestedSchema: { @@ -574,24 +427,11 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); test(`${validatorName}: should validate email format`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { email: 'user@example.com' } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - const result = await server.elicitInput({ message: 'Enter your email', requestedSchema: { @@ -629,18 +469,80 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo } ); + const testSchemaProperties: ElicitRequestParams['requestedSchema'] = { + type: 'object', + properties: { + subscribe: { type: 'boolean', default: true }, + nickname: { type: 'string', default: 'Guest' }, + age: { type: 'integer', minimum: 0, maximum: 150, default: 18 }, + color: { type: 'string', enum: ['red', 'green'], default: 'green' }, + untitledSingleSelectEnum: { + type: 'string', + title: 'Untitled Single Select Enum', + description: 'Choose your favorite color', + enum: ['red', 'green', 'blue'], + default: 'green' + }, + untitledMultipleSelectEnum: { + type: 'array', + title: 'Untitled Multiple Select Enum', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { type: 'string', enum: ['red', 'green', 'blue'] }, + default: ['green', 'blue'] + }, + titledSingleSelectEnum: { + type: 'string', + title: 'Single Select Enum', + description: 'Choose your favorite color', + oneOf: [ + { const: 'red', title: 'Red' }, + { const: 'green', title: 'Green' }, + { const: 'blue', title: 'Blue' } + ], + default: 'green' + }, + titledMultipleSelectEnum: { + type: 'array', + title: 'Multiple Select Enum', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'red', title: 'Red' }, + { const: 'green', title: 'Green' }, + { const: 'blue', title: 'Blue' } + ] + }, + default: ['green', 'blue'] + }, + legacyTitledEnum: { + type: 'string', + title: 'Legacy Titled Enum', + description: 'Choose your favorite color', + enum: ['red', 'green', 'blue'], + enumNames: ['Red', 'Green', 'Blue'], + default: 'green' + }, + optionalWithADefault: { type: 'string', default: 'default value' } + }, + required: [ + 'subscribe', + 'nickname', + 'age', + 'color', + 'titledSingleSelectEnum', + 'titledMultipleSelectEnum', + 'untitledSingleSelectEnum', + 'untitledMultipleSelectEnum' + ] + }; + // Client returns no values; SDK should apply defaults automatically (and validate) client.setRequestHandler(ElicitRequestSchema, request => { - expect(request.params.requestedSchema).toEqual({ - type: 'object', - properties: { - subscribe: { type: 'boolean', default: true }, - nickname: { type: 'string', default: 'Guest' }, - age: { type: 'integer', minimum: 0, maximum: 150, default: 18 }, - color: { type: 'string', enum: ['red', 'green'], default: 'green' } - }, - required: ['subscribe', 'nickname', 'age', 'color'] - }); + expect(request.params.requestedSchema).toEqual(testSchemaProperties); return { action: 'accept', content: {} @@ -652,16 +554,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo const result = await server.elicitInput({ message: 'Provide your preferences', - requestedSchema: { - type: 'object', - properties: { - subscribe: { type: 'boolean', default: true }, - nickname: { type: 'string', default: 'Guest' }, - age: { type: 'integer', minimum: 0, maximum: 150, default: 18 }, - color: { type: 'string', enum: ['red', 'green'], default: 'green' } - }, - required: ['subscribe', 'nickname', 'age', 'color'] - } + requestedSchema: testSchemaProperties }); expect(result).toEqual({ @@ -670,30 +563,23 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo subscribe: true, nickname: 'Guest', age: 18, - color: 'green' + color: 'green', + untitledSingleSelectEnum: 'green', + untitledMultipleSelectEnum: ['green', 'blue'], + titledSingleSelectEnum: 'green', + titledMultipleSelectEnum: ['green', 'blue'], + legacyTitledEnum: 'green', + optionalWithADefault: 'default value' } }); }); test(`${validatorName}: should reject invalid email format`, async () => { - const server = new Server( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: {}, - jsonSchemaValidator: validatorProvider - } - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); - client.setRequestHandler(ElicitRequestSchema, _request => ({ action: 'accept', content: { email: 'not-an-email' } })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect( server.elicitInput({ message: 'Enter your email', @@ -707,4 +593,369 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }) ).rejects.toThrow(/does not match requested schema/); }); + + // Enums - Valid - Single Select - Untitled / Titled + + test(`${validatorName}: should succeed with valid selection in single-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: 'Red' + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['Red', 'Green', 'Blue'], + default: 'Green' + } + }, + required: ['color'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + color: 'Red' + } + }); + }); + + test(`${validatorName}: should succeed with valid selection in single-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: '#FF0000' + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + oneOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + color: '#FF0000' + } + }); + }); + + test(`${validatorName}: should succeed with valid selection in single-select titled legacy enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: '#FF0000' + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['#FF0000', '#00FF00', '#0000FF'], + enumNames: ['Red', 'Green', 'Blue'], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + color: '#FF0000' + } + }); + }); + + // Enums - Valid - Multi Select - Untitled / Titled + + test(`${validatorName}: should succeed with valid selection in multi-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + colors: ['Red', 'Blue'] + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + colors: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['Red', 'Green', 'Blue'] + } + } + }, + required: ['colors'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + colors: ['Red', 'Blue'] + } + }); + }); + + test(`${validatorName}: should succeed with valid selection in multi-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + colors: ['#FF0000', '#0000FF'] + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + colors: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ] + } + } + }, + required: ['colors'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + colors: ['#FF0000', '#0000FF'] + } + }); + }); + + // Enums - Invalid - Single Select - Untitled / Titled + + test(`${validatorName}: should reject invalid selection in single-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: 'Black' // Color not in enum list + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['Red', 'Green', 'Blue'], + default: 'Green' + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid selection in single-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: 'Red' // Should be "#FF0000" (const not title) + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + oneOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid selection in single-select titled legacy enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: 'Red' // Should be "#FF0000" (enum not enumNames) + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['#FF0000', '#00FF00', '#0000FF'], + enumNames: ['Red', 'Green', 'Blue'], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/); + }); + + // Enums - Invalid - Multi Select - Untitled / Titled + + test(`${validatorName}: should reject invalid selection in multi-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + color: 'Red' // Should be array, not string + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['Red', 'Green', 'Blue'] + } + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid selection in multi-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + colors: ['Red', 'Blue'] // Should be ["#FF0000", "#0000FF"] (const not title) + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + colors: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ] + } + } + }, + required: ['colors'] + } + }) + ).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/); + }); } diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 05ae3d0ce..2417e6b1d 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -480,6 +480,34 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, + UntitledSingleSelectEnumSchema: (sdk: SDKTypes.UntitledSingleSelectEnumSchema, spec: SpecTypes.UntitledSingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + TitledSingleSelectEnumSchema: (sdk: SDKTypes.TitledSingleSelectEnumSchema, spec: SpecTypes.TitledSingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + SingleSelectEnumSchema: (sdk: SDKTypes.SingleSelectEnumSchema, spec: SpecTypes.SingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + UntitledMultiSelectEnumSchema: (sdk: SDKTypes.UntitledMultiSelectEnumSchema, spec: SpecTypes.UntitledMultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + TitledMultiSelectEnumSchema: (sdk: SDKTypes.TitledMultiSelectEnumSchema, spec: SpecTypes.TitledMultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + MultiSelectEnumSchema: (sdk: SDKTypes.MultiSelectEnumSchema, spec: SpecTypes.MultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + LegacyTitledEnumSchema: (sdk: SDKTypes.LegacyTitledEnumSchema, spec: SpecTypes.LegacyTitledEnumSchema) => { + sdk = spec; + spec = sdk; + }, PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: SpecTypes.PrimitiveSchemaDefinition) => { sdk = spec; spec = sdk; @@ -587,7 +615,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(112); + expect(specTypes).toHaveLength(119); }); it('should have up to date list of missing sdk types', () => { diff --git a/src/spec.types.ts b/src/spec.types.ts index 4b7a4edde..c58636350 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: bcdd3363e6472b645f196e7ec6988abe3b9799e2 + * Last updated from commit: 11ad2a720d8e2f54881235f734121db0bda39052 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: npm run fetch:spec-types @@ -55,6 +55,7 @@ export interface RequestParams { export interface Request { method: string; // Allow unofficial extensions of `Request.params` without impacting `RequestParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any params?: { [key: string]: any }; } @@ -66,11 +67,11 @@ export interface NotificationParams { _meta?: { [key: string]: unknown }; } - /** @internal */ export interface Notification { method: string; // Allow unofficial extensions of `Notification.params` without impacting `NotificationParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any params?: { [key: string]: any }; } @@ -95,7 +96,7 @@ export interface Error { * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). */ data?: unknown; -}; +} /** * A uniquely identifying ID for a request in JSON-RPC. @@ -360,7 +361,7 @@ export interface Icon { * * If not provided, the client should assume the icon can be used with any theme. */ - theme?: 'light' | 'dark'; + theme?: "light" | "dark"; } /** @@ -427,7 +428,7 @@ export interface Implementation extends BaseMetadata, Icons { */ export interface PingRequest extends JSONRPCRequest { method: "ping"; - params?: RequestParams + params?: RequestParams; } /* Progress notifications */ @@ -552,6 +553,7 @@ export interface ResourceRequestParams extends RequestParams { * * @category resources/read */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ReadResourceRequestParams extends ResourceRequestParams {} /** @@ -588,6 +590,7 @@ export interface ResourceListChangedNotification extends JSONRPCNotification { * * @category resources/subscribe */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface SubscribeRequestParams extends ResourceRequestParams {} /** @@ -605,6 +608,7 @@ export interface SubscribeRequest extends JSONRPCRequest { * * @category resources/unsubscribe */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UnsubscribeRequestParams extends ResourceRequestParams {} /** @@ -1647,15 +1651,176 @@ export interface BooleanSchema { default?: boolean; } -export interface EnumSchema { +/** + * Schema for single-selection enumeration without display titles for options. + */ +export interface UntitledSingleSelectEnumSchema { type: "string"; + /** + * Optional title for the enum field. + */ title?: string; + /** + * Optional description for the enum field. + */ description?: string; + /** + * Array of enum values to choose from. + */ enum: string[]; - enumNames?: string[]; // Display names for enum values + /** + * Optional default value. + */ default?: string; } +/** + * Schema for single-selection enumeration with display titles for each option. + */ +export interface TitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum options with values and display labels. + */ + oneOf: Array<{ + /** + * The enum value. + */ + const: string; + /** + * Display label for this option. + */ + title: string; + }>; + /** + * Optional default value. + */ + default?: string; +} + +// Combined single selection enumeration +export type SingleSelectEnumSchema = + | UntitledSingleSelectEnumSchema + | TitledSingleSelectEnumSchema; + +/** + * Schema for multiple-selection enumeration without display titles for options. + */ +export interface UntitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for the array items. + */ + items: { + type: "string"; + /** + * Array of enum values to choose from. + */ + enum: string[]; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * Schema for multiple-selection enumeration with display titles for each option. + */ +export interface TitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for array items with enum options and display labels. + */ + items: { + /** + * Array of enum options with values and display labels. + */ + anyOf: Array<{ + /** + * The constant enum value. + */ + const: string; + /** + * Display title for this option. + */ + title: string; + }>; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +// Combined multiple selection enumeration +export type MultiSelectEnumSchema = + | UntitledMultiSelectEnumSchema + | TitledMultiSelectEnumSchema; + +/** + * Use TitledSingleSelectEnumSchema instead. + * This interface will be removed in a future version. + */ +export interface LegacyTitledEnumSchema { + type: "string"; + title?: string; + description?: string; + enum: string[]; + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + */ + enumNames?: string[]; + default?: string; +} + +// Union type for all enum schemas +export type EnumSchema = + | SingleSelectEnumSchema + | MultiSelectEnumSchema + | LegacyTitledEnumSchema; + /** * The client's response to an elicitation request. * @@ -1674,7 +1839,7 @@ export interface ElicitResult extends Result { * The submitted form data, only present when action is "accept". * Contains values matching the requested schema. */ - content?: { [key: string]: string | number | boolean }; + content?: { [key: string]: string | number | boolean | string[] }; } /* Client messages */ diff --git a/src/types.ts b/src/types.ts index 9e9ec3d31..66cc34941 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1239,9 +1239,37 @@ export const NumberSchemaSchema = z.object({ }); /** - * Primitive schema definition for enum fields. + * Schema for single-selection enumeration without display titles for options. */ -export const EnumSchemaSchema = z.object({ +export const UntitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + default: z.string().optional() +}); + +/** + * Schema for single-selection enumeration with display titles for each option. + */ +export const TitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + oneOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ), + default: z.string().optional() +}); + +/** + * Use TitledSingleSelectEnumSchema instead. + * This interface will be removed in a future version. + */ +export const LegacyTitledEnumSchemaSchema = z.object({ type: z.literal('string'), title: z.string().optional(), description: z.string().optional(), @@ -1250,6 +1278,55 @@ export const EnumSchemaSchema = z.object({ default: z.string().optional() }); +// Combined single selection enumeration +export const SingleSelectEnumSchemaSchema = z.union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); + +/** + * Schema for multiple-selection enumeration without display titles for options. + */ +export const UntitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + type: z.literal('string'), + enum: z.array(z.string()) + }), + default: z.array(z.string()).optional() +}); + +/** + * Schema for multiple-selection enumeration with display titles for each option. + */ +export const TitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + anyOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ) + }), + default: z.array(z.string()).optional() +}); + +/** + * Combined schema for multiple-selection enumeration + */ +export const MultiSelectEnumSchemaSchema = z.union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); + /** * Union of all primitive schema definitions. */ @@ -1298,7 +1375,7 @@ export const ElicitResultSchema = ResultSchema.extend({ * The submitted form data, only present when action is "accept". * Contains values matching the requested schema. */ - content: z.record(z.union([z.string(), z.number(), z.boolean()])).optional() + content: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() }); /* Autocomplete */ @@ -1667,7 +1744,16 @@ export type CreateMessageResult = Infer; export type BooleanSchema = Infer; export type StringSchema = Infer; export type NumberSchema = Infer; + export type EnumSchema = Infer; +export type UntitledSingleSelectEnumSchema = Infer; +export type TitledSingleSelectEnumSchema = Infer; +export type LegacyTitledEnumSchema = Infer; +export type UntitledMultiSelectEnumSchema = Infer; +export type TitledMultiSelectEnumSchema = Infer; +export type SingleSelectEnumSchema = Infer; +export type MultiSelectEnumSchema = Infer; + export type PrimitiveSchemaDefinition = Infer; export type ElicitRequestParams = Infer; export type ElicitRequest = Infer;