diff --git a/src/__tests__/server.assertions.test.ts b/src/__tests__/server.assertions.test.ts new file mode 100644 index 0000000..9d0c714 --- /dev/null +++ b/src/__tests__/server.assertions.test.ts @@ -0,0 +1,243 @@ +import { + assertInput, + assertInputString, + assertInputStringLength, + assertInputStringArrayEntryLength, + assertInputStringNumberEnumLike +} from '../server.assertions'; + +describe('assertInput', () => { + it.each([ + { + description: 'basic string validation', + condition: ' '.trim().length > 0 + }, + { + description: 'pattern in string validation with callback format', + condition: () => new RegExp('patternfly://', 'i').test('fly://lorem-ipsum') + }, + { + description: 'array entry length validation', + condition: Array.isArray(['lorem']) && ['lorem'].length > 2 + } + ])('should throw an error for validation, $description', ({ condition }) => { + const errorMessage = `Lorem ipsum error message for validation.`; + + expect(() => assertInput( + condition, + errorMessage + )).toThrow(errorMessage); + }); + + it('should pass for a valid input', () => { + expect(() => assertInput('dolor'.length > 1, 'Lorem Ipsum')).not.toThrow(); + }); +}); + +describe('assertInputString', () => { + it.each([ + { + description: 'empty string', + input: '' + }, + { + description: 'undefined', + input: undefined + }, + { + description: 'number', + input: 1 + }, + { + description: 'null', + input: null + } + ])('should throw an error for validation, $description', ({ input }) => { + const errorMessage = '"Input" must be a non-empty string'; + + expect(() => assertInputString( + input + )).toThrow(errorMessage); + }); + + it('should pass for a valid string', () => { + expect(() => assertInputString('dolor')).not.toThrow(); + }); +}); + +describe('assertInputStringLength', () => { + it.each([ + { + description: 'empty string', + input: '' + }, + { + description: 'undefined', + input: undefined + }, + { + description: 'number', + input: 1 + }, + { + description: 'null', + input: null + }, + { + description: 'max', + input: 'lorem ipsum', + options: { max: 5 } + }, + { + description: 'min', + input: 'lorem ipsum', + options: { min: 15 } + }, + { + description: 'max and min', + input: 'lorem ipsum', + options: { min: 1, max: 10 } + }, + { + description: 'max and min and display name', + input: 'lorem ipsum', + options: { min: 1, max: 10, inputDisplayName: 'lorem ipsum' } + }, + { + description: 'max and min and description', + input: 'lorem ipsum', + options: { min: 1, max: 10, message: 'dolor sit amet, consectetur adipiscing elit.' } + } + ])('should throw an error for validation, $description', ({ input, options }) => { + const errorMessage = options?.message || `"${options?.inputDisplayName || 'Input'}" must be a string from`; + + expect(() => assertInputStringLength( + input, + { min: 1, max: 100, ...options } + )).toThrow(errorMessage); + }); + + it('should pass for a valid string within range', () => { + expect(() => assertInputStringLength('dolor', { min: 1, max: 10 })).not.toThrow(); + }); +}); + +describe('assertInputStringArrayEntryLength', () => { + it.each([ + { + description: 'empty string', + input: '' + }, + { + description: 'undefined', + input: undefined + }, + { + description: 'number', + input: 1 + }, + { + description: 'null', + input: null + }, + { + description: 'max', + input: ['lorem ipsum'], + options: { max: 5 } + }, + { + description: 'min', + input: ['lorem ipsum'], + options: { min: 15 } + }, + { + description: 'max and min', + input: ['lorem ipsum'], + options: { min: 1, max: 10 } + }, + { + description: 'max and min and display name', + input: ['lorem ipsum'], + options: { min: 1, max: 10, inputDisplayName: 'lorem ipsum' } + }, + { + description: 'max and min and description', + input: ['lorem ipsum'], + options: { min: 1, max: 10, message: 'dolor sit amet, consectetur adipiscing elit.' } + } + ])('should throw an error for validation, $description', ({ input, options }) => { + const errorMessage = options?.message || `"${options?.inputDisplayName || 'Input'}" array must contain strings`; + + expect(() => assertInputStringArrayEntryLength( + input, + { min: 1, max: 100, ...options } + )).toThrow(errorMessage); + }); + + it('should pass for a valid array of strings', () => { + expect(() => assertInputStringArrayEntryLength(['dolor'], { min: 1, max: 10 })).not.toThrow(); + }); +}); + +describe('assertInputStringNumberEnumLike', () => { + it.each([ + { + description: 'empty string', + input: '', + compare: [2, 3] + }, + { + description: 'undefined', + input: undefined, + compare: [2, 3] + }, + { + description: 'null', + input: null, + compare: [2, 3] + }, + { + description: 'number', + input: 1, + compare: [2, 3] + }, + { + description: 'string', + input: 'lorem ipsum', + compare: ['amet', 'dolor sit'] + }, + { + description: 'string and display name', + input: 'lorem ipsum', + compare: ['amet', 'dolor sit'], + options: { inputDisplayName: 'lorem ipsum' } + }, + { + description: 'string and description', + input: 'lorem ipsum', + compare: [1, 2], + options: { message: 'dolor sit amet, consectetur adipiscing elit.' } + } + ])('should throw an error for validation, $description', ({ input, compare, options }) => { + const errorMessage = options?.message || `"${options?.inputDisplayName || 'Input'}" must be one of the following values`; + + expect(() => assertInputStringNumberEnumLike( + input, + compare, + { ...options } + )).toThrow(errorMessage); + }); + + it('should throw an internal error for validation when missing comparison values', () => { + const errorMessage = 'List of allowed values is empty'; + + expect(() => assertInputStringNumberEnumLike( + 1, + [] + )).toThrow(errorMessage); + }); + + it('should pass for a valid value in enum-like array', () => { + expect(() => assertInputStringNumberEnumLike('dolor', ['dolor'])).not.toThrow(); + }); +}); diff --git a/src/server.assertions.ts b/src/server.assertions.ts new file mode 100644 index 0000000..3c12fbe --- /dev/null +++ b/src/server.assertions.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; + +/** + * MCP assert. Centralizes and throws an error if the validation fails. + * + * @param condition - Function or condition to be validated. + * @param message - Thrown error message, or function, that returns the error message. + * @param {ErrorCode} [code] - Thrown error code when validation fails. Defaults to `ErrorCode.InvalidParams`. + * + * @throws {McpError} Throw the provided error message and code on failure. + */ +const mcpAssert = (condition: unknown, message: string | (() => string), code: ErrorCode = ErrorCode.InvalidParams) => { + try { + const result = typeof condition === 'function' ? condition() : condition; + const resultMessage = typeof message === 'function' ? message() : message; + + assert.ok(result, resultMessage); + } catch (error) { + throw new McpError(code, (error as Error).message); + } +}; + +/** + * General purpose input assert/validation function. + * + * @alias mcpAssert + * + * @param condition - Function or condition to be validated. + * @param message - Thrown error message, or function, that returns the error message. + * @param {ErrorCode} [code] - Thrown error code when validation fails. Defaults to `ErrorCode.InvalidParams`. + * + * @throws {McpError} Throw the provided error message and code on failure. + */ +function assertInput( + condition: unknown, + message: string | (() => string), + code?: ErrorCode +): asserts condition { + mcpAssert(condition, message, code); +} + +/** + * Assert/validate if the input is a non-empty string. + * + * @param input - Input value + * @param [options] - Validation options + * @param [options.inputDisplayName] - Display name for the input. Used in the default error message. Defaults to 'Input'. + * @param [options.message] - Custom error message. A default error message with optional `inputDisplayName` is generated if not provided. + * + * @throws McpError If input is not a non-empty string. + */ +function assertInputString( + input: unknown, + { inputDisplayName, message }: { inputDisplayName?: string, message?: string } = {} +): asserts input is string { + const isValid = typeof input === 'string' && input.trim().length > 0; + + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a non-empty string`); +} + +/** + * Assert/validate if the input is a string, non-empty, and meets min and max length requirements. + * + * @param input - Input string + * @param options - Validation options + * @param options.max - Maximum length of the string. `Required` + * @param options.min - Minimum length of the string. `Required` + * @param [options.inputDisplayName] - Display name for the input. Used in the default error message. Defaults to 'Input'. + * @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided. + * + * @throws McpError If input is not a string or does not meet length requirements. + */ +function assertInputStringLength( + input: unknown, + { max, min, inputDisplayName, message }: { max: number, min: number, inputDisplayName?: string, message?: string } +): asserts input is string { + const isValid = typeof input === 'string' && input.trim().length <= max && input.trim().length >= min; + + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a string from ${min} to ${max} characters`); +} + +/** + * Assert/validate if input array entries are strings and have min and max length. + * + * @param input - Array of strings + * @param options - Validation options + * @param options.max - Maximum length of each string in the array. `Required` + * @param options.min - Minimum length of each string in the array. `Required` + * @param [options.inputDisplayName] - Display name for the input. Used in the default error messages. Defaults to 'Input'. + * @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided. + * + * @throws McpError If input is not an array of strings or array entries do not meet length requirements. + */ +function assertInputStringArrayEntryLength( + input: unknown, + { max, min, inputDisplayName, message }: { max: number, min: number, inputDisplayName?: string, message?: string } +): asserts input is string[] { + const isValid = Array.isArray(input) && input.every(entry => typeof entry === 'string' && entry.trim().length <= max && entry.trim().length >= min); + + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" array must contain strings from ${min} to ${max} characters`); +} + +/** + * Assert/validate if input is a string or number and is one of the allowed values. + * + * @param input - The input value + * @param values - List of allowed values + * @param [options] - Validation options + * @param [options.inputDisplayName] - Display name for the input. Used in the default error messages. Defaults to 'Input'. + * @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided. + * + * @throws McpError If input is not a string or number or is not one of the allowed values. + */ +function assertInputStringNumberEnumLike( + input: unknown, + values: unknown, + { inputDisplayName, message }: { inputDisplayName?: string, message?: string } = {} +): asserts input is string | number { + const hasArrayWithLength = Array.isArray(values) && values.length > 0; + let updatedDescription; + let errorCode; + + if (hasArrayWithLength) { + errorCode = ErrorCode.InvalidParams; + updatedDescription = message || `"${inputDisplayName || 'Input'}" must be one of the following values: ${values.join(', ')}`; + } else { + errorCode = ErrorCode.InternalError; + updatedDescription = `Unable to confirm "${inputDisplayName || 'input'}." List of allowed values is empty or undefined.`; + } + + const isStringOrNumber = typeof input === 'string' || typeof input === 'number'; + const isValid = isStringOrNumber && hasArrayWithLength && values.includes(input); + + mcpAssert(isValid, updatedDescription, errorCode); +} + +export { + assertInput, + assertInputString, + assertInputStringLength, + assertInputStringArrayEntryLength, + assertInputStringNumberEnumLike +};