diff --git a/src/__tests__/server.assertions.test.ts b/src/__tests__/server.assertions.test.ts index 9d0c714e..7bf4979f 100644 --- a/src/__tests__/server.assertions.test.ts +++ b/src/__tests__/server.assertions.test.ts @@ -3,7 +3,8 @@ import { assertInputString, assertInputStringLength, assertInputStringArrayEntryLength, - assertInputStringNumberEnumLike + assertInputStringNumberEnumLike, + assertInputUrlWhiteListed } from '../server.assertions'; describe('assertInput', () => { @@ -32,6 +33,16 @@ describe('assertInput', () => { it('should pass for a valid input', () => { expect(() => assertInput('dolor'.length > 1, 'Lorem Ipsum')).not.toThrow(); }); + + it('should allow custom errors when provided', () => { + const errorMessage = 'Lorem ipsum error message for validation.'; + + expect(() => assertInput( + false, + errorMessage, + (message, cause) => new Error(`Custom: ${message}`, { cause }) + )).toThrow(`Custom: ${errorMessage}`); + }); }); describe('assertInputString', () => { @@ -63,6 +74,15 @@ describe('assertInputString', () => { it('should pass for a valid string', () => { expect(() => assertInputString('dolor')).not.toThrow(); }); + + it('should allow custom errors when provided', () => { + const errorMessage = 'Lorem ipsum error message for validation.'; + + expect(() => assertInputString( + false, + { message: errorMessage, codeOrError: (message, cause) => new Error(`Custom: ${message}`, { cause }) } + )).toThrow(`Custom: ${errorMessage}`); + }); }); describe('assertInputStringLength', () => { @@ -120,6 +140,15 @@ describe('assertInputStringLength', () => { it('should pass for a valid string within range', () => { expect(() => assertInputStringLength('dolor', { min: 1, max: 10 })).not.toThrow(); }); + + it('should allow custom errors when provided', () => { + const errorMessage = 'Lorem ipsum error message for validation.'; + + expect(() => assertInputStringLength( + false, + { min: 1, max: 10, message: errorMessage, codeOrError: (message, cause) => new Error(`Custom: ${message}`, { cause }) } + )).toThrow(`Custom: ${errorMessage}`); + }); }); describe('assertInputStringArrayEntryLength', () => { @@ -177,6 +206,15 @@ describe('assertInputStringArrayEntryLength', () => { it('should pass for a valid array of strings', () => { expect(() => assertInputStringArrayEntryLength(['dolor'], { min: 1, max: 10 })).not.toThrow(); }); + + it('should allow custom errors when provided', () => { + const errorMessage = 'Lorem ipsum error message for validation.'; + + expect(() => assertInputStringArrayEntryLength( + false, + { min: 1, max: 10, message: errorMessage, codeOrError: (message, cause) => new Error(`Custom: ${message}`, { cause }) } + )).toThrow(`Custom: ${errorMessage}`); + }); }); describe('assertInputStringNumberEnumLike', () => { @@ -240,4 +278,53 @@ describe('assertInputStringNumberEnumLike', () => { it('should pass for a valid value in enum-like array', () => { expect(() => assertInputStringNumberEnumLike('dolor', ['dolor'])).not.toThrow(); }); + + it('should allow custom errors when provided', () => { + const errorMessage = 'Lorem ipsum error message for validation.'; + + expect(() => assertInputStringNumberEnumLike( + false, + ['dolor'], + { message: errorMessage, codeOrError: (message, cause) => new Error(`Custom: ${message}`, { cause }) } + )).toThrow(`Custom: ${errorMessage}`); + }); +}); + +describe('assertInputUrlWhiteListed', () => { + it.each([ + { + description: 'URL and display name', + input: 'https://github.com/patternfly', + compare: ['https://patternfly.org'], + options: { inputDisplayName: 'lorem ipsum' } + }, + { + description: 'URL and description', + input: 'https://github.com/patternfly', + compare: ['https://patternfly.org'], + options: { message: 'dolor sit amet, consectetur adipiscing elit.' } + } + ])('should throw an error for validation, $description', ({ input, compare, options }) => { + const errorMessage = options?.message || `"${options?.inputDisplayName || 'URL input'}" must be within the whitelisted URLs`; + + expect(() => assertInputUrlWhiteListed( + input, + compare as any, + { ...options } + )).toThrow(errorMessage); + }); + + it('should pass for a valid value in a whitelist array', () => { + expect(() => assertInputUrlWhiteListed('https://patternfly.org', ['https://patternfly.org'])).not.toThrow(); + }); + + it('should allow custom errors when provided', () => { + const errorMessage = 'Lorem ipsum error message for validation.'; + + expect(() => assertInputUrlWhiteListed( + 'https://github.com/patternfly', + ['http://patternfly.org'], + { message: errorMessage, codeOrError: (message, cause) => new Error(`Custom: ${message}`, { cause }) } + )).toThrow(`Custom: ${errorMessage}`); + }); }); diff --git a/src/options.assertions.ts b/src/options.assertions.ts index 03ee76c5..c4ab9055 100644 --- a/src/options.assertions.ts +++ b/src/options.assertions.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { mcpAssert } from './server.assertions'; +import { mcpAssert, type AssertCodeOrError } from './server.assertions'; /** * Validates that each URL in the provided list uses one of the allowed schemes. @@ -7,10 +7,18 @@ import { mcpAssert } from './server.assertions'; * @param urls - The list of URLs to be validated. * @param protocols - Allowed scheme names (e.g. `['http', 'https']`). Each URL’s scheme is compared * after normalizing to include a trailing colon (e.g. `http` matches `http:`). + * @param [options] - Validation options + * @param [options.codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. * * @throws {Error} Throws an error if any URL does not use one of the specified protocols. + * @throws Error When `codeOrError` is provided an error factory. */ -const assertProtocol = (urls: string[], protocols: string[]) => { +const assertProtocol = ( + urls: string[], + protocols: string[], + { codeOrError }: { codeOrError?: AssertCodeOrError } = {} +) => { const validate = z.array( z.string().url().refine( url => { @@ -36,7 +44,8 @@ const assertProtocol = (urls: string[], protocols: string[]) => { mcpAssert( result.success, - () => `Invalid URL protocol configuration: ${result.error?.message || urls.map(url => url.slice(0, 50)).join(', ')}` + () => `Invalid URL protocol configuration: ${result.error?.message || urls.map(url => url.slice(0, 50)).join(', ')}`, + codeOrError ); }; diff --git a/src/server.assertions.ts b/src/server.assertions.ts index 3a7c5381..7f579074 100644 --- a/src/server.assertions.ts +++ b/src/server.assertions.ts @@ -8,23 +8,50 @@ import { import { isPatternFlyUri } from './patternFly.support'; import { DEFAULT_OPTIONS, type WhitelistUrl } from './options.defaults'; +/** + * Type for assertion error instances. + * + * @param message - Error message describing the reason for the assertion. + * @param cause - Cause of the error, typically another error or additional information. + * @returns The constructed `Error` instance. + */ +type AssertErrorFactory = (message: string, cause: unknown) => Error; + +/** + * Type for an `ErrorCode` or `AssertErrorFactory` associated with parameter + * `codeOrError` see {@link mcpAssert}. + */ +type AssertCodeOrError = ErrorCode | AssertErrorFactory; + /** * 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`. + * @param {AssertCodeOrError} [codeOrError] - Thrown error code when validation fails OR + * a function that returns an error. Defaults to `ErrorCode.InvalidParams`. * - * @throws {McpError} Throw the provided error message and code on failure. + * @throws {McpError} By default throw the provided MCP error message and code on failure. + * @throws {Error} When `codeOrError` is provided an error factory. */ -const mcpAssert = (condition: unknown, message: string | (() => string), code: ErrorCode = ErrorCode.InvalidParams) => { +const mcpAssert = ( + condition: unknown, + message: string | (() => string), + codeOrError: AssertCodeOrError = 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); + const errorMessage = (error as Error).message; + + if (typeof codeOrError === 'function') { + throw codeOrError(errorMessage, error); + } + + throw new McpError(codeOrError, errorMessage); } }; @@ -35,16 +62,18 @@ const mcpAssert = (condition: unknown, message: string | (() => string), code: E * * @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`. + * @param [codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. * - * @throws {McpError} Throw the provided error message and code on failure. + * @throws McpError By default throw the provided MCP error message and code on failure. + * @throws Error When `codeOrError` is provided an error factory. */ function assertInput( condition: unknown, message: string | (() => string), - code?: ErrorCode + codeOrError?: AssertCodeOrError ): asserts condition { - mcpAssert(condition, message, code); + mcpAssert(condition, message, codeOrError); } /** @@ -52,18 +81,25 @@ function assertInput( * * @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. + * @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. + * @param [options.codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. * * @throws McpError If input is not a non-empty string. + * @throws Error When `codeOrError` is provided an error factory. */ function assertInputString( input: unknown, - { inputDisplayName, message }: { inputDisplayName?: string; message?: string; } = {} + { + inputDisplayName, message, codeOrError + }: { inputDisplayName?: string; message?: string; codeOrError?: AssertCodeOrError } = {} ): asserts input is string { const isValid = typeof input === 'string' && input.trim().length > 0; - mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a non-empty string`); + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a non-empty string`, codeOrError); } /** @@ -73,18 +109,25 @@ function assertInputString( * @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. + * @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. + * @param [options.codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. * * @throws McpError If input is not a string or does not meet length requirements. + * @throws Error When `codeOrError` is provided an error factory. */ function assertInputStringLength( input: unknown, - { max, min, inputDisplayName, message }: { max: number; min: number; inputDisplayName?: string; message?: string } + { + max, min, inputDisplayName, message, codeOrError + }: { max: number; min: number; inputDisplayName?: string; message?: string; codeOrError?: AssertCodeOrError } ): 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`); + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a string from ${min} to ${max} characters`, codeOrError); } /** @@ -94,18 +137,25 @@ function assertInputStringLength( * @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. + * @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. + * @param [options.codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. * * @throws McpError If input is not an array of strings or array entries do not meet length requirements. + * @throws Error When `codeOrError` is provided an error factory. */ function assertInputStringArrayEntryLength( input: unknown, - { max, min, inputDisplayName, message }: { max: number; min: number; inputDisplayName?: string; message?: string } + { + max, min, inputDisplayName, message, codeOrError + }: { max: number; min: number; inputDisplayName?: string; message?: string; codeOrError?: AssertCodeOrError } ): 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`); + mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" array must contain strings from ${min} to ${max} characters`, codeOrError); } /** @@ -114,15 +164,22 @@ function assertInputStringArrayEntryLength( * @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. + * @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. + * @param [options.codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. * * @throws McpError If input is not a string or number or is not one of the allowed values. + * @throws Error When `codeOrError` is provided an error factory. */ function assertInputStringNumberEnumLike( input: unknown, values: unknown, - { inputDisplayName, message }: { inputDisplayName?: string; message?: string } = {} + { + inputDisplayName, message, codeOrError + }: { inputDisplayName?: string; message?: string; codeOrError?: AssertCodeOrError } = {} ): asserts input is string | number { const hasArrayWithLength = Array.isArray(values) && values.length > 0; let updatedDescription; @@ -139,11 +196,12 @@ function assertInputStringNumberEnumLike( const isStringOrNumber = typeof input === 'string' || typeof input === 'number'; const isValid = isStringOrNumber && hasArrayWithLength && values.includes(input); - mcpAssert(isValid, updatedDescription, errorCode); + mcpAssert(isValid, updatedDescription, codeOrError ?? errorCode); } /** - * Assert/validate that a given input URL string, or array of URL strings, is whitelisted against a provided list of URLs. + * Assert/validate that a given input URL string, or array of URL strings, is whitelisted against a + * provided list of URLs. * * @param input - Input URL string, or array of URL strings, to validate. * @param {WhitelistUrl[]} whitelist - The list of allowed URLs to compare against. @@ -152,12 +210,19 @@ function assertInputStringNumberEnumLike( * @param [options.inputDisplayName] - Optional display name for the input parameter, used in error messages. * @param [options.message] - Optional custom error message to override the default message. * @param [options.urlDisplayMaxLength] - Optional maximum length of an invalid URL to display in error messages + * @param [options.codeOrError] - Thrown error code when validation fails OR a function that returns an + * error. Defaults to `ErrorCode.InvalidParams`. + * + * @throws McpError If input is not an allowed URL against the provided whitelist. + * @throws Error When `codeOrError` is provided an error factory. */ function assertInputUrlWhiteListed( input: unknown, whitelist: WhitelistUrl[], - { allowedProtocols = DEFAULT_OPTIONS.whitelist.protocols, inputDisplayName, message, urlDisplayMaxLength = 50 }: { - allowedProtocols?: string[]; inputDisplayName?: string; message?: string; urlDisplayMaxLength?: number + { + allowedProtocols = DEFAULT_OPTIONS.whitelist.protocols, inputDisplayName, message, urlDisplayMaxLength = 50, codeOrError + }: { + allowedProtocols?: string[]; inputDisplayName?: string; message?: string; urlDisplayMaxLength?: number; codeOrError?: AssertCodeOrError } = {} ): asserts input is string | string[] { const updatedInput = Array.isArray(input) ? input : [input]; @@ -183,7 +248,7 @@ function assertInputUrlWhiteListed( `Use official PatternFly documentation sources.`, ...invalidUrls.map(invalid => `Invalid URL: ${String(invalid).slice(0, urlDisplayMaxLength)}...`) ), - ErrorCode.InvalidParams + codeOrError ?? ErrorCode.InvalidParams ); } @@ -194,5 +259,7 @@ export { assertInputStringLength, assertInputStringArrayEntryLength, assertInputStringNumberEnumLike, - assertInputUrlWhiteListed + assertInputUrlWhiteListed, + type AssertErrorFactory, + type AssertCodeOrError };