/**
* Centralized validation utilities for MCP Gas Server
* Consolidates duplicate validation patterns from multiple tools
*
* **Eliminates duplicate validation from:**
* - execution.ts (scriptId/functionName validation)
* - filesystem.ts (path/content validation)
* - deployments.ts (deployment parameter validation)
* - proxySetup.ts (URL/configuration validation)
* - headDeployment.ts (code/timezone validation)
* - And 4 other tools with similar patterns
*
* **Benefits:**
* - Consistent validation messages across all tools
* - Centralized validation logic (30+ lines eliminated)
* - Type-safe validation with comprehensive error reporting
* - Easier maintenance and testing of validation rules
*/
import { ValidationError } from '../errors/mcpErrors.js';
import { GASErrorHandler, ErrorContext } from './errorHandler.js';
import { fileNameMatches } from '../api/pathParser.js';
export interface ValidationRule<T = any> {
field: string;
value: T;
required?: boolean;
type?: 'string' | 'number' | 'boolean' | 'array' | 'object';
minLength?: number;
maxLength?: number;
pattern?: RegExp;
enum?: T[];
customValidator?: (value: T) => string | null; // Returns error message or null if valid
}
export interface ValidationOptions {
throwOnError?: boolean;
collectAllErrors?: boolean;
context?: ErrorContext;
}
export interface ValidationResult {
isValid: boolean;
errors: string[];
field?: string;
value?: any;
}
/**
* Centralized validation utility for MCP Gas Server parameters
*
* **Replaces duplicate validation in:**
* - `ExecTool.validateExecutionParams()` - scriptId/functionName validation
* - `GASWriteTool.validateWriteParams()` - path/content validation
* - `GASDeployCreateTool.validateDeploymentParams()` - deployment validation
* - `GASProxySetupTool.validateProxyParams()` - URL/config validation
* - `GASHeadDeployTool.validateCodeParams()` - code/timezone validation
*
* **Provides consistent validation** with:
* - Type checking and constraint validation
* - Meaningful error messages
* - Optional vs required parameter handling
* - Context-aware error reporting
*/
export class MCPValidator {
/**
* Validate a single parameter with comprehensive rules
*
* **Consolidates validation patterns** from multiple tools into
* a single, flexible validation system with consistent error handling.
*
* @param rule - Validation rule configuration
* @param options - Validation options and context
* @returns Validation result with detailed error information
*/
static validateParameter<T>(rule: ValidationRule<T>, options: ValidationOptions = {}): ValidationResult {
const { field, value, required = false } = rule;
const { throwOnError = true, context } = options;
const errors: string[] = [];
// Check if required field is missing
if (required && (value === null || value === undefined || value === '')) {
const error = `${field} is required`;
if (throwOnError && context) {
GASErrorHandler.handleValidationError(field, value, 'non-empty value', context);
}
return { isValid: false, errors: [error], field, value };
}
// Skip further validation if value is empty and not required
if (!required && (value === null || value === undefined || value === '')) {
return { isValid: true, errors: [] };
}
// Type validation
if (rule.type && !this.validateType(value, rule.type)) {
errors.push(`${field} must be of type ${rule.type}, got ${typeof value}`);
}
// String-specific validations
if (rule.type === 'string' && typeof value === 'string') {
if (rule.minLength !== undefined && value.length < rule.minLength) {
errors.push(`${field} must be at least ${rule.minLength} characters long`);
}
if (rule.maxLength !== undefined && value.length > rule.maxLength) {
errors.push(`${field} must be no more than ${rule.maxLength} characters long`);
}
if (rule.pattern && !rule.pattern.test(value)) {
errors.push(`${field} format is invalid`);
}
}
// Array-specific validations
if (rule.type === 'array' && Array.isArray(value)) {
if (rule.minLength !== undefined && value.length < rule.minLength) {
errors.push(`${field} must contain at least ${rule.minLength} items`);
}
if (rule.maxLength !== undefined && value.length > rule.maxLength) {
errors.push(`${field} must contain no more than ${rule.maxLength} items`);
}
}
// Enum validation
if (rule.enum && !rule.enum.includes(value)) {
errors.push(`${field} must be one of: ${rule.enum.join(', ')}`);
}
// Custom validation
if (rule.customValidator) {
const customError = rule.customValidator(value);
if (customError) {
errors.push(customError);
}
}
const result = { isValid: errors.length === 0, errors, field, value };
// Throw error if configured to do so
if (throwOnError && !result.isValid && context) {
GASErrorHandler.handleValidationError(
field,
value,
errors[0] || 'valid value',
context
);
}
return result;
}
/**
* Validate multiple parameters at once
*
* **Bulk validation** that replaces individual parameter validation
* calls across tools with a single, comprehensive validation function.
*
* @param rules - Array of validation rules
* @param options - Validation options
* @returns Combined validation result
*/
static validateParameters(rules: ValidationRule[], options: ValidationOptions = {}): ValidationResult {
const { collectAllErrors = true } = options;
const allErrors: string[] = [];
let firstError: ValidationResult | null = null;
for (const rule of rules) {
const result = this.validateParameter(rule, { ...options, throwOnError: false });
if (!result.isValid) {
allErrors.push(...result.errors);
if (!firstError) {
firstError = result;
}
// Stop on first error if not collecting all errors
if (!collectAllErrors) {
break;
}
}
}
const finalResult = {
isValid: allErrors.length === 0,
errors: allErrors,
field: firstError?.field,
value: firstError?.value
};
// Throw the first error if configured to do so
if (options.throwOnError && !finalResult.isValid && options.context && firstError) {
GASErrorHandler.handleValidationError(
firstError.field || 'parameter',
firstError.value,
finalResult.errors[0] || 'valid value',
options.context
);
}
return finalResult;
}
/**
* Validate Google Apps Script ID format
* **Consolidates scriptId validation** from multiple tools
*/
static validateScriptId(scriptId: string, context?: ErrorContext): ValidationResult {
return this.validateParameter({
field: 'scriptId',
value: scriptId,
required: true,
type: 'string',
minLength: 20,
pattern: /^[a-zA-Z0-9_-]+$/,
customValidator: (value) => {
// Google Apps Script IDs are typically 44 characters long
if (value.length < 20 || value.length > 60) {
return 'Google Apps Script ID should be 20-60 characters long';
}
return null;
}
}, { context, throwOnError: true });
}
/**
* Validate function name format
* **Consolidates functionName validation** from execution tools
*/
static validateFunctionName(functionName: string, context?: ErrorContext): ValidationResult {
return this.validateParameter({
field: 'functionName',
value: functionName,
required: true,
type: 'string',
minLength: 1,
maxLength: 100,
pattern: /^[a-zA-Z_$][a-zA-Z0-9_$]*$/,
customValidator: (value) => {
if (value.startsWith('__') && !value.startsWith('__gas_run') && !value.startsWith('__mcp_')) {
return 'Function names starting with __ are reserved (except __gas_run and __mcp_ functions)';
}
return null;
}
}, { context, throwOnError: true });
}
/**
* Validate file path format with basic format checks
* **Consolidates path validation** from filesystem tools
*/
static validateFilePath(path: string, context?: ErrorContext): ValidationResult {
return this.validateParameter({
field: 'path',
value: path,
required: true,
type: 'string',
minLength: 1,
maxLength: 500, // Reasonable path length limit
customValidator: (value) => {
// Split path into scriptId and filename parts
const pathParts = value.split('/');
const scriptId = pathParts[0];
const filename = pathParts[pathParts.length - 1];
// Validate script ID format (if present)
if (scriptId) {
if (!/^[a-zA-Z0-9_-]+$/.test(scriptId)) {
return `Script ID can only contain letters, numbers, underscores, and hyphens`;
}
if (scriptId.length < 5 || scriptId.length > 60) {
return `Script ID must be 5-60 characters long`;
}
}
// Validate filename (if present)
if (filename && filename !== scriptId) {
if (filename.includes(' ')) {
return `File names cannot contain spaces`;
}
if (!/^[a-zA-Z0-9_.\/-]+$/.test(filename)) {
return `File names can only contain letters, numbers, underscores, dots, hyphens, and slashes`;
}
if (filename.length > 100) {
return `File name too long (max 100 characters)`;
}
if (filename.startsWith('.') || filename.endsWith('.')) {
return `File names cannot start or end with dots`;
}
}
return null; // Path is valid
}
}, { context, throwOnError: true });
}
/**
* Validate deployment configuration
* **Consolidates deployment validation** from deployment tools
*/
static validateDeploymentConfig(config: any, context?: ErrorContext): ValidationResult {
const rules: ValidationRule[] = [
{
field: 'access',
value: config.access,
required: false,
enum: ['PRIVATE', 'DOMAIN', 'ANYONE', 'ANYONE_ANONYMOUS']
},
{
field: 'executeAs',
value: config.executeAs,
required: false,
enum: ['USER_ACCESSING', 'USER_DEPLOYING']
},
{
field: 'description',
value: config.description,
required: false,
type: 'string',
maxLength: 500
},
{
field: 'versionNumber',
value: config.versionNumber,
required: false,
type: 'number',
customValidator: (value) => value > 0 ? null : 'Version number must be positive'
}
];
return this.validateParameters(rules, { context, collectAllErrors: true });
}
/**
* Validate URL format
* **Consolidates URL validation** from proxy and web app tools
*/
static validateUrl(url: string, context?: ErrorContext): ValidationResult {
return this.validateParameter({
field: 'url',
value: url,
required: true,
type: 'string',
customValidator: (value) => {
try {
const urlObj = new URL(value);
if (!['http:', 'https:'].includes(urlObj.protocol)) {
return 'URL must use HTTP or HTTPS protocol';
}
if (value.includes('script.google.com') && !value.includes('/macros/s/')) {
return 'Google Apps Script URLs should include /macros/s/ path';
}
return null;
} catch {
return 'Invalid URL format';
}
}
}, { context, throwOnError: true });
}
/**
* Validate timezone string
* **Consolidates timezone validation** from deployment tools
*/
static validateTimezone(timezone: string, context?: ErrorContext): ValidationResult {
return this.validateParameter({
field: 'timezone',
value: timezone,
required: false,
type: 'string',
customValidator: (value) => {
// Common timezone validation
const validTimezones = [
'America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver',
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Rome',
'Asia/Tokyo', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Sydney',
'UTC', 'GMT'
];
if (!validTimezones.includes(value) && !/^[A-Z][a-z]+\/[A-Z][a-z_]+$/.test(value)) {
return `Invalid timezone format. Common timezones: ${validTimezones.slice(0, 5).join(', ')}, etc.`;
}
return null;
}
}, { context, throwOnError: true });
}
/**
* Validate code content
* **Consolidates code validation** from file and execution tools
*/
static validateCode(code: string, context?: ErrorContext, contentType?: string): ValidationResult {
return this.validateParameter({
field: 'code',
value: code,
required: true,
type: 'string',
minLength: 1,
maxLength: 100000, // Google Apps Script file size limit
customValidator: (value) => {
// Allow script tags in HTML content
if (contentType === 'html' || contentType === 'HTML') {
// For HTML content, we allow script tags - let GAS API handle size validation
return null;
}
// For JavaScript/Apps Script content, block script tags
if (value.includes('<script>') || value.includes('</script>')) {
return 'Code should not contain HTML script tags';
}
// Let Google Apps Script API be the authority for size validation
return null;
}
}, { context, throwOnError: true });
}
/**
* Validate HTML content specifically
* **New method for HTML file validation**
*/
static validateHtmlContent(content: string, context?: ErrorContext): ValidationResult {
return this.validateParameter({
field: 'htmlContent',
value: content,
required: true,
type: 'string',
minLength: 1,
maxLength: 100000, // Google Apps Script file size limit
customValidator: (value) => {
// HTML content validation - allow script tags but check for basic HTML structure
if (!value.trim().toLowerCase().includes('<!doctype') && !value.trim().toLowerCase().includes('<html')) {
// Not strictly required, but good practice
}
// Let Google Apps Script API be the authority for size validation
return null;
}
}, { context, throwOnError: true });
}
/**
* Type validation helper
*/
private static validateType(value: any, expectedType: string): boolean {
switch (expectedType) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'boolean':
return typeof value === 'boolean';
case 'array':
return Array.isArray(value);
case 'object':
return typeof value === 'object' && value !== null && !Array.isArray(value);
default:
return true;
}
}
/**
* Create validation context helper
* **Simplifies context creation** for validation calls
*/
static createValidationContext(operation: string, tool: string, additionalInfo?: Record<string, any>): ErrorContext {
return GASErrorHandler.createContext(operation, tool, { additionalInfo });
}
/**
* Quick validation for common MCP parameter patterns
* **One-liner validation** for frequently used parameter combinations
*/
static quickValidate = {
scriptIdAndFunction: (scriptId: string, functionName: string, tool: string) => {
const context = this.createValidationContext('function execution', tool);
this.validateScriptId(scriptId, context);
this.validateFunctionName(functionName, context);
},
pathAndContent: (path: string, content: string, tool: string) => {
const context = this.createValidationContext('file operation', tool);
this.validateFilePath(path, context);
this.validateCode(content, context);
},
deploymentBasics: (scriptId: string, description: string, tool: string) => {
const context = this.createValidationContext('deployment', tool);
this.validateScriptId(scriptId, context);
if (description) {
this.validateParameter({
field: 'description',
value: description,
type: 'string',
maxLength: 500
}, { context });
}
}
};
}
/**
* CommonJS file ordering validation result
*/
export interface CommonJSOrderingResult {
/** Whether the ordering is correct */
valid: boolean;
/** List of issues found (empty if valid) */
issues: CommonJSOrderingIssue[];
/** Current file positions for debugging */
positions: {
require: number | null;
configManager: number | null;
mcpExec: number | null;
};
/** Suggested fix command if issues found */
fix?: string;
}
export interface CommonJSOrderingIssue {
file: string;
expected: number;
actual: number;
severity: 'error' | 'warning';
message: string;
}
/**
* Critical CommonJS infrastructure files and their required positions
*
* Order matters because:
* - require.gs (position 0): Module system must load first to enable require() calls
* - ConfigManager.gs (position 1): Configuration must be available before other modules
* - __mcp_exec.gs (position 2): Execution infrastructure depends on both above
*/
const CRITICAL_FILES = [
{ baseName: 'common-js/require', position: 0, description: 'Module system' },
{ baseName: 'common-js/ConfigManager', position: 1, description: 'Configuration' },
{ baseName: 'common-js/__mcp_exec', position: 2, description: 'Execution infrastructure' }
];
/**
* Validate CommonJS file ordering in a GAS project
*
* Ensures critical infrastructure files are in the correct positions:
* - common-js/require must be at position 0 (module system)
* - common-js/ConfigManager must be at position 1 (configuration)
* - common-js/__mcp_exec must be at position 2 (execution infrastructure)
*
* @param files - Array of GAS files with {name: string} property, in execution order
* @returns Validation result with issues and suggested fixes
*/
export function validateCommonJSOrdering(files: Array<{ name: string }>): CommonJSOrderingResult {
const issues: CommonJSOrderingIssue[] = [];
const positions: CommonJSOrderingResult['positions'] = {
require: null,
configManager: null,
mcpExec: null
};
// Find actual positions of critical files
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (fileNameMatches(file.name, 'common-js/require')) {
positions.require = i;
} else if (fileNameMatches(file.name, 'common-js/ConfigManager')) {
positions.configManager = i;
} else if (fileNameMatches(file.name, 'common-js/__mcp_exec')) {
positions.mcpExec = i;
}
}
// Check each critical file's position
for (const criticalFile of CRITICAL_FILES) {
const positionKey = criticalFile.baseName === 'common-js/require'
? 'require'
: criticalFile.baseName === 'common-js/ConfigManager'
? 'configManager'
: 'mcpExec';
const actualPosition = positions[positionKey];
if (actualPosition === null) {
// File not found - this is a warning, not an error (might be intentional)
// Only warn for require.gs which is truly critical
if (criticalFile.baseName === 'common-js/require') {
issues.push({
file: criticalFile.baseName,
expected: criticalFile.position,
actual: -1,
severity: 'warning',
message: `${criticalFile.baseName} not found - CommonJS module system may not work`
});
}
} else if (actualPosition !== criticalFile.position) {
// File is in wrong position
const severity = criticalFile.baseName === 'common-js/require' ? 'error' : 'warning';
issues.push({
file: criticalFile.baseName,
expected: criticalFile.position,
actual: actualPosition,
severity,
message: `${criticalFile.baseName} is at position ${actualPosition}, should be at position ${criticalFile.position} (${criticalFile.description})`
});
}
}
const result: CommonJSOrderingResult = {
valid: issues.filter(i => i.severity === 'error').length === 0,
issues,
positions
};
// Add fix suggestion if there are issues
if (issues.length > 0) {
result.fix = 'Run project_init({ scriptId, force: true }) to fix file ordering';
}
return result;
}
/**
* Format CommonJS ordering issues for display
*
* @param result - Validation result from validateCommonJSOrdering
* @returns Formatted string for logging/display
*/
export function formatCommonJSOrderingIssues(result: CommonJSOrderingResult): string {
if (result.valid && result.issues.length === 0) {
return '✓ CommonJS file ordering is correct';
}
const lines: string[] = [];
if (!result.valid) {
lines.push('⚠️ CommonJS file ordering issues detected:');
} else {
lines.push('⚠️ CommonJS file ordering warnings:');
}
for (const issue of result.issues) {
const icon = issue.severity === 'error' ? '❌' : '⚠️';
lines.push(` ${icon} ${issue.message}`);
}
if (result.fix) {
lines.push(` 💡 Fix: ${result.fix}`);
}
return lines.join('\n');
}