Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions src/__tests__/server.assertions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
144 changes: 144 additions & 0 deletions src/server.assertions.ts
Original file line number Diff line number Diff line change
@@ -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
};
Loading