Skip to content

Commit d537fd6

Browse files
authored
feat: nodejs assertions base (#113)
1 parent a7861d1 commit d537fd6

2 files changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import {
2+
assertInput,
3+
assertInputString,
4+
assertInputStringLength,
5+
assertInputStringArrayEntryLength,
6+
assertInputStringNumberEnumLike
7+
} from '../server.assertions';
8+
9+
describe('assertInput', () => {
10+
it.each([
11+
{
12+
description: 'basic string validation',
13+
condition: ' '.trim().length > 0
14+
},
15+
{
16+
description: 'pattern in string validation with callback format',
17+
condition: () => new RegExp('patternfly://', 'i').test('fly://lorem-ipsum')
18+
},
19+
{
20+
description: 'array entry length validation',
21+
condition: Array.isArray(['lorem']) && ['lorem'].length > 2
22+
}
23+
])('should throw an error for validation, $description', ({ condition }) => {
24+
const errorMessage = `Lorem ipsum error message for validation.`;
25+
26+
expect(() => assertInput(
27+
condition,
28+
errorMessage
29+
)).toThrow(errorMessage);
30+
});
31+
32+
it('should pass for a valid input', () => {
33+
expect(() => assertInput('dolor'.length > 1, 'Lorem Ipsum')).not.toThrow();
34+
});
35+
});
36+
37+
describe('assertInputString', () => {
38+
it.each([
39+
{
40+
description: 'empty string',
41+
input: ''
42+
},
43+
{
44+
description: 'undefined',
45+
input: undefined
46+
},
47+
{
48+
description: 'number',
49+
input: 1
50+
},
51+
{
52+
description: 'null',
53+
input: null
54+
}
55+
])('should throw an error for validation, $description', ({ input }) => {
56+
const errorMessage = '"Input" must be a non-empty string';
57+
58+
expect(() => assertInputString(
59+
input
60+
)).toThrow(errorMessage);
61+
});
62+
63+
it('should pass for a valid string', () => {
64+
expect(() => assertInputString('dolor')).not.toThrow();
65+
});
66+
});
67+
68+
describe('assertInputStringLength', () => {
69+
it.each([
70+
{
71+
description: 'empty string',
72+
input: ''
73+
},
74+
{
75+
description: 'undefined',
76+
input: undefined
77+
},
78+
{
79+
description: 'number',
80+
input: 1
81+
},
82+
{
83+
description: 'null',
84+
input: null
85+
},
86+
{
87+
description: 'max',
88+
input: 'lorem ipsum',
89+
options: { max: 5 }
90+
},
91+
{
92+
description: 'min',
93+
input: 'lorem ipsum',
94+
options: { min: 15 }
95+
},
96+
{
97+
description: 'max and min',
98+
input: 'lorem ipsum',
99+
options: { min: 1, max: 10 }
100+
},
101+
{
102+
description: 'max and min and display name',
103+
input: 'lorem ipsum',
104+
options: { min: 1, max: 10, inputDisplayName: 'lorem ipsum' }
105+
},
106+
{
107+
description: 'max and min and description',
108+
input: 'lorem ipsum',
109+
options: { min: 1, max: 10, message: 'dolor sit amet, consectetur adipiscing elit.' }
110+
}
111+
])('should throw an error for validation, $description', ({ input, options }) => {
112+
const errorMessage = options?.message || `"${options?.inputDisplayName || 'Input'}" must be a string from`;
113+
114+
expect(() => assertInputStringLength(
115+
input,
116+
{ min: 1, max: 100, ...options }
117+
)).toThrow(errorMessage);
118+
});
119+
120+
it('should pass for a valid string within range', () => {
121+
expect(() => assertInputStringLength('dolor', { min: 1, max: 10 })).not.toThrow();
122+
});
123+
});
124+
125+
describe('assertInputStringArrayEntryLength', () => {
126+
it.each([
127+
{
128+
description: 'empty string',
129+
input: ''
130+
},
131+
{
132+
description: 'undefined',
133+
input: undefined
134+
},
135+
{
136+
description: 'number',
137+
input: 1
138+
},
139+
{
140+
description: 'null',
141+
input: null
142+
},
143+
{
144+
description: 'max',
145+
input: ['lorem ipsum'],
146+
options: { max: 5 }
147+
},
148+
{
149+
description: 'min',
150+
input: ['lorem ipsum'],
151+
options: { min: 15 }
152+
},
153+
{
154+
description: 'max and min',
155+
input: ['lorem ipsum'],
156+
options: { min: 1, max: 10 }
157+
},
158+
{
159+
description: 'max and min and display name',
160+
input: ['lorem ipsum'],
161+
options: { min: 1, max: 10, inputDisplayName: 'lorem ipsum' }
162+
},
163+
{
164+
description: 'max and min and description',
165+
input: ['lorem ipsum'],
166+
options: { min: 1, max: 10, message: 'dolor sit amet, consectetur adipiscing elit.' }
167+
}
168+
])('should throw an error for validation, $description', ({ input, options }) => {
169+
const errorMessage = options?.message || `"${options?.inputDisplayName || 'Input'}" array must contain strings`;
170+
171+
expect(() => assertInputStringArrayEntryLength(
172+
input,
173+
{ min: 1, max: 100, ...options }
174+
)).toThrow(errorMessage);
175+
});
176+
177+
it('should pass for a valid array of strings', () => {
178+
expect(() => assertInputStringArrayEntryLength(['dolor'], { min: 1, max: 10 })).not.toThrow();
179+
});
180+
});
181+
182+
describe('assertInputStringNumberEnumLike', () => {
183+
it.each([
184+
{
185+
description: 'empty string',
186+
input: '',
187+
compare: [2, 3]
188+
},
189+
{
190+
description: 'undefined',
191+
input: undefined,
192+
compare: [2, 3]
193+
},
194+
{
195+
description: 'null',
196+
input: null,
197+
compare: [2, 3]
198+
},
199+
{
200+
description: 'number',
201+
input: 1,
202+
compare: [2, 3]
203+
},
204+
{
205+
description: 'string',
206+
input: 'lorem ipsum',
207+
compare: ['amet', 'dolor sit']
208+
},
209+
{
210+
description: 'string and display name',
211+
input: 'lorem ipsum',
212+
compare: ['amet', 'dolor sit'],
213+
options: { inputDisplayName: 'lorem ipsum' }
214+
},
215+
{
216+
description: 'string and description',
217+
input: 'lorem ipsum',
218+
compare: [1, 2],
219+
options: { message: 'dolor sit amet, consectetur adipiscing elit.' }
220+
}
221+
])('should throw an error for validation, $description', ({ input, compare, options }) => {
222+
const errorMessage = options?.message || `"${options?.inputDisplayName || 'Input'}" must be one of the following values`;
223+
224+
expect(() => assertInputStringNumberEnumLike(
225+
input,
226+
compare,
227+
{ ...options }
228+
)).toThrow(errorMessage);
229+
});
230+
231+
it('should throw an internal error for validation when missing comparison values', () => {
232+
const errorMessage = 'List of allowed values is empty';
233+
234+
expect(() => assertInputStringNumberEnumLike(
235+
1,
236+
[]
237+
)).toThrow(errorMessage);
238+
});
239+
240+
it('should pass for a valid value in enum-like array', () => {
241+
expect(() => assertInputStringNumberEnumLike('dolor', ['dolor'])).not.toThrow();
242+
});
243+
});

src/server.assertions.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import assert from 'node:assert';
2+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3+
4+
/**
5+
* MCP assert. Centralizes and throws an error if the validation fails.
6+
*
7+
* @param condition - Function or condition to be validated.
8+
* @param message - Thrown error message, or function, that returns the error message.
9+
* @param {ErrorCode} [code] - Thrown error code when validation fails. Defaults to `ErrorCode.InvalidParams`.
10+
*
11+
* @throws {McpError} Throw the provided error message and code on failure.
12+
*/
13+
const mcpAssert = (condition: unknown, message: string | (() => string), code: ErrorCode = ErrorCode.InvalidParams) => {
14+
try {
15+
const result = typeof condition === 'function' ? condition() : condition;
16+
const resultMessage = typeof message === 'function' ? message() : message;
17+
18+
assert.ok(result, resultMessage);
19+
} catch (error) {
20+
throw new McpError(code, (error as Error).message);
21+
}
22+
};
23+
24+
/**
25+
* General purpose input assert/validation function.
26+
*
27+
* @alias mcpAssert
28+
*
29+
* @param condition - Function or condition to be validated.
30+
* @param message - Thrown error message, or function, that returns the error message.
31+
* @param {ErrorCode} [code] - Thrown error code when validation fails. Defaults to `ErrorCode.InvalidParams`.
32+
*
33+
* @throws {McpError} Throw the provided error message and code on failure.
34+
*/
35+
function assertInput(
36+
condition: unknown,
37+
message: string | (() => string),
38+
code?: ErrorCode
39+
): asserts condition {
40+
mcpAssert(condition, message, code);
41+
}
42+
43+
/**
44+
* Assert/validate if the input is a non-empty string.
45+
*
46+
* @param input - Input value
47+
* @param [options] - Validation options
48+
* @param [options.inputDisplayName] - Display name for the input. Used in the default error message. Defaults to 'Input'.
49+
* @param [options.message] - Custom error message. A default error message with optional `inputDisplayName` is generated if not provided.
50+
*
51+
* @throws McpError If input is not a non-empty string.
52+
*/
53+
function assertInputString(
54+
input: unknown,
55+
{ inputDisplayName, message }: { inputDisplayName?: string, message?: string } = {}
56+
): asserts input is string {
57+
const isValid = typeof input === 'string' && input.trim().length > 0;
58+
59+
mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a non-empty string`);
60+
}
61+
62+
/**
63+
* Assert/validate if the input is a string, non-empty, and meets min and max length requirements.
64+
*
65+
* @param input - Input string
66+
* @param options - Validation options
67+
* @param options.max - Maximum length of the string. `Required`
68+
* @param options.min - Minimum length of the string. `Required`
69+
* @param [options.inputDisplayName] - Display name for the input. Used in the default error message. Defaults to 'Input'.
70+
* @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided.
71+
*
72+
* @throws McpError If input is not a string or does not meet length requirements.
73+
*/
74+
function assertInputStringLength(
75+
input: unknown,
76+
{ max, min, inputDisplayName, message }: { max: number, min: number, inputDisplayName?: string, message?: string }
77+
): asserts input is string {
78+
const isValid = typeof input === 'string' && input.trim().length <= max && input.trim().length >= min;
79+
80+
mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" must be a string from ${min} to ${max} characters`);
81+
}
82+
83+
/**
84+
* Assert/validate if input array entries are strings and have min and max length.
85+
*
86+
* @param input - Array of strings
87+
* @param options - Validation options
88+
* @param options.max - Maximum length of each string in the array. `Required`
89+
* @param options.min - Minimum length of each string in the array. `Required`
90+
* @param [options.inputDisplayName] - Display name for the input. Used in the default error messages. Defaults to 'Input'.
91+
* @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided.
92+
*
93+
* @throws McpError If input is not an array of strings or array entries do not meet length requirements.
94+
*/
95+
function assertInputStringArrayEntryLength(
96+
input: unknown,
97+
{ max, min, inputDisplayName, message }: { max: number, min: number, inputDisplayName?: string, message?: string }
98+
): asserts input is string[] {
99+
const isValid = Array.isArray(input) && input.every(entry => typeof entry === 'string' && entry.trim().length <= max && entry.trim().length >= min);
100+
101+
mcpAssert(isValid, message || `"${inputDisplayName || 'Input'}" array must contain strings from ${min} to ${max} characters`);
102+
}
103+
104+
/**
105+
* Assert/validate if input is a string or number and is one of the allowed values.
106+
*
107+
* @param input - The input value
108+
* @param values - List of allowed values
109+
* @param [options] - Validation options
110+
* @param [options.inputDisplayName] - Display name for the input. Used in the default error messages. Defaults to 'Input'.
111+
* @param [options.message] - Error description. A default error message with optional `inputDisplayName` is generated if not provided.
112+
*
113+
* @throws McpError If input is not a string or number or is not one of the allowed values.
114+
*/
115+
function assertInputStringNumberEnumLike(
116+
input: unknown,
117+
values: unknown,
118+
{ inputDisplayName, message }: { inputDisplayName?: string, message?: string } = {}
119+
): asserts input is string | number {
120+
const hasArrayWithLength = Array.isArray(values) && values.length > 0;
121+
let updatedDescription;
122+
let errorCode;
123+
124+
if (hasArrayWithLength) {
125+
errorCode = ErrorCode.InvalidParams;
126+
updatedDescription = message || `"${inputDisplayName || 'Input'}" must be one of the following values: ${values.join(', ')}`;
127+
} else {
128+
errorCode = ErrorCode.InternalError;
129+
updatedDescription = `Unable to confirm "${inputDisplayName || 'input'}." List of allowed values is empty or undefined.`;
130+
}
131+
132+
const isStringOrNumber = typeof input === 'string' || typeof input === 'number';
133+
const isValid = isStringOrNumber && hasArrayWithLength && values.includes(input);
134+
135+
mcpAssert(isValid, updatedDescription, errorCode);
136+
}
137+
138+
export {
139+
assertInput,
140+
assertInputString,
141+
assertInputStringLength,
142+
assertInputStringArrayEntryLength,
143+
assertInputStringNumberEnumLike
144+
};

0 commit comments

Comments
 (0)