wyreup-mcp
Version:
Production-ready MCP server that transforms automation platform webhooks into reliable, agent-callable tools
241 lines (210 loc) • 10.2 kB
JavaScript
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import chalk from 'chalk';
// Constants for validation
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
const VALID_AUTH_TYPES = ['header', 'jwt'];
const REQUIRED_FIELDS = ['name'];
const SIMPLIFIED_REQUIRED_FIELDS = ['name', 'webhook'];
const FULL_REQUIRED_FIELDS = ['name', 'description', 'url'];
// Validation messages for easier localization/editing
const MESSAGES = {
INVALID_NAME: (name) => `Tool has invalid name: ${JSON.stringify(name)}. Must be a non-empty string.`,
INVALID_DESCRIPTION: (toolName, desc) => `Tool "${toolName}" has invalid description: ${JSON.stringify(desc)}. Must be a non-empty string.`,
INVALID_URL: (toolName, url) => `Tool "${toolName}" has invalid url: ${JSON.stringify(url)}. Must be a non-empty string.`,
INVALID_METHOD: (toolName, method) => `Tool "${toolName}" has invalid method: ${JSON.stringify(method)}. Must be one of: ${VALID_METHODS.join(', ')}.`,
INVALID_INPUT_SCHEMA: (toolName) => `Tool "${toolName}" has invalid input schema. Must be an object if defined.`,
INVALID_OUTPUT_SCHEMA: (toolName) => `Tool "${toolName}" has invalid output schema. Must be an object if defined.`,
INVALID_AUTH: (toolName) => `Tool "${toolName}" has invalid auth. Must be an object if defined.`,
INVALID_AUTH_TYPE: (toolName, type) => `Tool "${toolName}" has invalid auth.type: ${JSON.stringify(type)}. Must be one of: ${VALID_AUTH_TYPES.join(', ')}.`,
INVALID_AUTH_HEADER_NAME: (toolName) => `Tool "${toolName}" auth.name is required for header auth type.`,
INVALID_AUTH_HEADER_VALUE: (toolName) => `Tool "${toolName}" auth.value is required for header auth type.`,
INVALID_AUTH_JWT_TOKEN: (toolName) => `Tool "${toolName}" auth.token is required for jwt auth type.`,
UNKNOWN_FIELD: (toolName, field) => `Tool "${toolName}" has unknown field: "${field}". This may be a typo.`,
};
/**
* Centralized tool validation for MCP compatibility
* @param {Object} tool - Tool configuration object
* @param {boolean} throwOnError - Whether to throw McpError or return boolean
* @param {boolean} debug - Whether to show debug logging
* @returns {boolean} - Returns true if valid (when throwOnError=false)
* @throws {McpError} - When throwOnError=true and validation fails
*/
export function validateTool(tool, throwOnError = false, debug = false) {
const errors = [];
// Validate required fields
if (!tool.name || typeof tool.name !== 'string' || !tool.name.trim()) {
errors.push(MESSAGES.INVALID_NAME(tool.name));
}
// Check if this is a simplified format (has webhook) or full format (has url)
const isSimplifiedFormat = tool.webhook && !tool.url;
const isFullFormat = tool.url && !tool.webhook;
const isMixedFormat = tool.webhook && tool.url;
if (isMixedFormat) {
errors.push(`Tool "${tool.name || 'unknown'}" cannot have both "webhook" and "url" fields. Use either simplified format (webhook) or full format (url).`);
}
if (isSimplifiedFormat) {
// Validate simplified format
if (!tool.webhook || typeof tool.webhook !== 'string' || !tool.webhook.trim()) {
errors.push(`Tool "${tool.name || 'unknown'}" has invalid webhook: ${JSON.stringify(tool.webhook)}. Must be a non-empty string.`);
}
} else if (isFullFormat) {
// Validate full format - require all traditional fields
if (!tool.description || typeof tool.description !== 'string' || !tool.description.trim()) {
errors.push(MESSAGES.INVALID_DESCRIPTION(tool.name || 'unknown', tool.description));
}
if (!tool.url || typeof tool.url !== 'string' || !tool.url.trim()) {
errors.push(MESSAGES.INVALID_URL(tool.name || 'unknown', tool.url));
}
} else {
// Neither simplified nor full format
errors.push(`Tool "${tool.name || 'unknown'}" must have either "webhook" (simplified format) or "url" with "description" (full format).`);
}
// Validate method (optional, defaults to GET)
if (tool.method !== undefined) {
if (typeof tool.method !== 'string' || !VALID_METHODS.includes(tool.method.toUpperCase())) {
errors.push(MESSAGES.INVALID_METHOD(tool.name || 'unknown', tool.method));
}
}
// Validate input schema (optional)
if (tool.input !== undefined) {
if (typeof tool.input !== 'object' || tool.input === null || Array.isArray(tool.input)) {
errors.push(MESSAGES.INVALID_INPUT_SCHEMA(tool.name || 'unknown'));
}
}
// Validate output schema (optional)
if (tool.output !== undefined) {
if (typeof tool.output !== 'object' || tool.output === null || Array.isArray(tool.output)) {
errors.push(MESSAGES.INVALID_OUTPUT_SCHEMA(tool.name || 'unknown'));
}
}
// Validate auth (optional)
if (tool.auth !== undefined) {
if (typeof tool.auth !== 'object' || tool.auth === null || Array.isArray(tool.auth)) {
errors.push(MESSAGES.INVALID_AUTH(tool.name || 'unknown'));
} else {
// Validate auth structure
if (!tool.auth.type || typeof tool.auth.type !== 'string' || !VALID_AUTH_TYPES.includes(tool.auth.type)) {
errors.push(MESSAGES.INVALID_AUTH_TYPE(tool.name || 'unknown', tool.auth.type));
} else {
// Validate auth type-specific fields
if (tool.auth.type === 'header') {
if (!tool.auth.name || typeof tool.auth.name !== 'string' || !tool.auth.name.trim()) {
errors.push(MESSAGES.INVALID_AUTH_HEADER_NAME(tool.name || 'unknown'));
}
// Allow valueFromEnv as alternative to value for header auth
const hasValue = tool.auth.value && typeof tool.auth.value === 'string';
const hasValueFromEnv = tool.auth.valueFromEnv && typeof tool.auth.valueFromEnv === 'string';
if (!hasValue && !hasValueFromEnv) {
errors.push(`Tool "${tool.name || 'unknown'}" auth requires either "value" or "valueFromEnv" for header auth type.`);
}
} else if (tool.auth.type === 'jwt') {
// Allow tokenFromEnv as alternative to token for JWT auth
const hasToken = tool.auth.token && typeof tool.auth.token === 'string' && tool.auth.token.trim();
const hasTokenFromEnv = tool.auth.tokenFromEnv && typeof tool.auth.tokenFromEnv === 'string';
if (!hasToken && !hasTokenFromEnv) {
errors.push(`Tool "${tool.name || 'unknown'}" auth requires either "token" or "tokenFromEnv" for jwt auth type.`);
}
}
}
}
}
// Validate webhook-specific configurations
if (tool.timeout !== undefined) {
if (typeof tool.timeout !== 'number' || tool.timeout <= 0) {
errors.push(`Tool "${tool.name || 'unknown'}" has invalid timeout: ${tool.timeout}. Must be a positive number (milliseconds).`);
}
}
if (tool.maxRetries !== undefined) {
if (typeof tool.maxRetries !== 'number' || tool.maxRetries < 0 || tool.maxRetries > 10) {
errors.push(`Tool "${tool.name || 'unknown'}" has invalid maxRetries: ${tool.maxRetries}. Must be 0-10.`);
}
}
if (tool.retryDelay !== undefined) {
if (typeof tool.retryDelay !== 'number' || tool.retryDelay < 0) {
errors.push(`Tool "${tool.name || 'unknown'}" has invalid retryDelay: ${tool.retryDelay}. Must be a non-negative number (milliseconds).`);
}
}
if (tool.rateLimit !== undefined) {
if (typeof tool.rateLimit !== 'object' || !tool.rateLimit.requests || !tool.rateLimit.window) {
errors.push(`Tool "${tool.name || 'unknown'}" has invalid rateLimit. Must be {requests: number, window: number}.`);
}
}
// Check for unknown/extra fields (expanded for webhook features)
const knownFields = [
'name', 'description', 'url', 'webhook', 'method', 'input', 'output', 'auth', 'authFrom',
'public', 'paid', 'timeout', 'maxRetries', 'retryDelay', 'rateLimit',
'webhookVerification', 'healthCheck', 'tags'
];
const unknownFields = Object.keys(tool).filter(field => !knownFields.includes(field));
if (unknownFields.length > 0 && debug) {
unknownFields.forEach(field => {
console.warn(chalk.yellow(`[DEBUG] ${MESSAGES.UNKNOWN_FIELD(tool.name || 'unknown', field)}`));
});
}
// Handle errors
if (errors.length > 0) {
const firstError = errors[0];
if (throwOnError) {
throw new McpError(ErrorCode.InvalidRequest, firstError);
}
if (debug) {
errors.forEach(error => {
console.warn(chalk.yellow(`[DEBUG] ${error}`));
});
}
return false;
}
return true;
}
/**
* Validate an entire manifest with tools array
* @param {Object} config - Parsed manifest configuration
* @param {string} filePath - Path to manifest file (for error reporting)
* @param {boolean} debug - Whether to show debug logging
* @returns {Object} - Validation result with success boolean and errors array
*/
export function validateManifest(config, filePath, debug = false) {
const errors = [];
// Check tools array
if (!Array.isArray(config.tools)) {
errors.push('Field "tools": Missing or invalid. Must be an array.');
return { success: false, errors };
}
if (config.tools.length === 0) {
errors.push('Field "tools": Array must contain at least one tool.');
return { success: false, errors };
}
// Check for duplicate tool names
const toolNames = new Set();
const duplicates = [];
config.tools.forEach((tool, index) => {
// Validate individual tool
try {
if (!validateTool(tool, false, debug)) {
errors.push(`Tool at index ${index}: Invalid tool configuration`);
}
} catch (error) {
errors.push(`Tool at index ${index}: ${error.message}`);
}
// Check for duplicate names
if (tool.name && typeof tool.name === 'string') {
if (toolNames.has(tool.name)) {
duplicates.push(tool.name);
} else {
toolNames.add(tool.name);
}
}
});
// Report duplicates
if (duplicates.length > 0) {
duplicates.forEach(name => {
errors.push(`Duplicate tool name found: "${name}". Tool names must be unique.`);
});
}
return {
success: errors.length === 0,
errors,
toolCount: config.tools.length,
toolNames: Array.from(toolNames)
};
}