Skip to main content
Glama
schema-pre-validation.ts22.5 kB
/** * Schema Pre-validation Utility * * Validates record data against available attributes before API calls. * Uses discover-attributes to ensure fields exist and are correctly formatted. */ import { UniversalResourceType } from '../handlers/tool-configs/universal/types.js'; import { UniversalValidationError, ErrorType, HttpStatusCode, } from '../handlers/tool-configs/universal/schemas.js'; import { warn, OperationType } from './logger.js'; // Import discover attribute handlers import { discoverCompanyAttributes } from '../objects/companies/index.js'; /** * Attribute metadata from discover-attributes */ export interface AttributeMetadata { id: string; slug: string; name: string; type: string; is_system?: boolean; is_writable?: boolean; is_required?: boolean; allowed_values?: unknown[]; format?: string; } /** * Cached attribute schemas by resource type */ const attributeCache: Map< string, { attributes: AttributeMetadata[]; timestamp: number; } > = new Map(); // Cache TTL: 5 minutes const CACHE_TTL = 5 * 60 * 1000; /** * Schema pre-validation service */ export class SchemaPreValidator { /** * Generate cache key with tenant/workspace context */ private static getCacheKey( resourceType: UniversalResourceType, context?: { workspaceId?: string; tenantId?: string } ): string { // Build cache key with optional context for multi-tenant support const parts = [resourceType.toLowerCase()]; // Add workspace context if available (from environment or passed context) const workspaceId = context?.workspaceId || process.env.ATTIO_WORKSPACE_ID; if (workspaceId) { parts.push(`ws:${workspaceId}`); } // Add tenant context if available (for future multi-tenant support) const tenantId = context?.tenantId || process.env.ATTIO_TENANT_ID; if (tenantId) { parts.push(`tenant:${tenantId}`); } return parts.join(':'); } /** * Get attributes for a resource type */ static async getAttributes( resourceType: UniversalResourceType, context?: { workspaceId?: string; tenantId?: string } ): Promise<AttributeMetadata[]> { const cacheKey = this.getCacheKey(resourceType, context); const cached = attributeCache.get(cacheKey); // Check cache validity if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return cached.attributes; } try { let attributes: AttributeMetadata[] = []; switch (resourceType) { case UniversalResourceType.COMPANIES: try { const companyAttrs = await discoverCompanyAttributes(); attributes = this.normalizeAttributes(companyAttrs); // If no attributes returned, use defaults if (attributes.length === 0) { attributes = this.getDefaultCompanyAttributes(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_error: unknown) { // Fallback to defaults on error attributes = this.getDefaultCompanyAttributes(); } break; case UniversalResourceType.PEOPLE: // FEATURE: Dynamic person attributes discovery // Requires: Attio API /objects/people/attributes endpoint // Fallback: Default people attributes schema attributes = this.getDefaultPeopleAttributes(); break; case UniversalResourceType.DEALS: // FEATURE: Dynamic deal attributes discovery // Requires: Attio API /objects/deals/attributes endpoint // Status: Pending Attio deals API feature availability attributes = this.getDefaultDealAttributes(); break; case UniversalResourceType.TASKS: // TODO: Implement when task discover-attributes is available attributes = this.getDefaultTaskAttributes(); break; default: // For unknown types, return basic attributes attributes = this.getDefaultAttributes(); } // Update cache attributeCache.set(cacheKey, { attributes, timestamp: Date.now(), }); return attributes; } catch (error: unknown) { warn( 'utils/schema-pre-validation', `Failed to fetch attributes for ${resourceType}`, { resourceType, error: error instanceof Error ? error.message : String(error), }, 'fetchAttributes', OperationType.API_CALL ); // Return default attributes on error return this.getDefaultAttributes(); } } /** * Normalize attributes from API response */ private static normalizeAttributes( apiResponse: unknown ): AttributeMetadata[] { // Handle both array format (direct attributes) and object format (discovery response) let attributesList: Record<string, unknown>[] = []; if (Array.isArray(apiResponse)) { attributesList = apiResponse; } else if (apiResponse && typeof apiResponse === 'object') { const responseObject = apiResponse as Record<string, unknown>; // Handle discoverCompanyAttributes response format: { standard: [], custom: [], all: [] } if (Array.isArray(responseObject.all)) { // Handle both string array and object array formats attributesList = responseObject.all.map((attr: unknown) => { if (typeof attr === 'string') { return { slug: attr, name: attr }; } return attr as Record<string, unknown>; // Already an object }); } else if (Array.isArray(responseObject.standard)) { attributesList = responseObject.standard.map((attr: unknown) => { if (typeof attr === 'string') { return { slug: attr, name: attr }; } return attr as Record<string, unknown>; }); } } if (attributesList.length === 0) { return []; } // Helper to create a safe slug from any string const toSlug = (input: unknown): string | undefined => { if (typeof input !== 'string' || input.length === 0) return undefined; return input .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .replace(/_{2,}/g, '_'); }; // Normalize and filter invalid entries defensively const normalizedAttrs = attributesList .map((attr: Record<string, unknown>) => { const rawSlug = attr?.slug ?? attr?.id ?? attr?.name ?? attr?.title; const slug = toSlug(rawSlug); if (!slug) return undefined; return { id: (attr?.id as string) || slug, slug, name: (attr?.name as string) || (attr?.title as string) || slug, type: (attr?.value_type as string) || (attr?.type as string) || 'text', is_system: Boolean(attr?.is_system) || false, is_writable: attr?.is_writable !== false, // Treat name as required by default is_required: slug === 'name' ? true : Boolean(attr?.is_required) || false, allowed_values: (attr?.allowed_values as unknown[]) || (attr?.options as unknown[]), format: attr?.format as string, } as AttributeMetadata; }) .filter((a: AttributeMetadata | undefined): a is AttributeMetadata => Boolean(a && a.slug) ); return normalizedAttrs; } /** * Get default attributes for companies */ private static getDefaultCompanyAttributes(): AttributeMetadata[] { return [ { id: 'name', slug: 'name', name: 'Name', type: 'text', is_system: true, is_required: true, }, { id: 'domains', slug: 'domains', name: 'Domains', type: 'text', is_system: true, }, { id: 'description', slug: 'description', name: 'Description', type: 'text', is_system: true, }, { id: 'team', slug: 'team', name: 'Team', type: 'record-reference', is_system: true, }, { id: 'categories', slug: 'categories', name: 'Categories', type: 'select', is_system: true, }, { id: 'primary_location', slug: 'primary_location', name: 'Primary Location', type: 'location', is_system: true, }, { id: 'angellist', slug: 'angellist', name: 'AngelList', type: 'url', is_system: true, }, { id: 'facebook', slug: 'facebook', name: 'Facebook', type: 'url', is_system: true, }, { id: 'instagram', slug: 'instagram', name: 'Instagram', type: 'url', is_system: true, }, { id: 'linkedin', slug: 'linkedin', name: 'LinkedIn', type: 'url', is_system: true, }, { id: 'twitter', slug: 'twitter', name: 'Twitter', type: 'url', is_system: true, }, { id: 'associated_deals', slug: 'associated_deals', name: 'Associated Deals', type: 'record-reference', is_system: true, }, { id: 'associated_workspaces', slug: 'associated_workspaces', name: 'Associated Workspaces', type: 'record-reference', is_system: true, }, ]; } /** * Get default attributes for people */ private static getDefaultPeopleAttributes(): AttributeMetadata[] { return [ { id: 'name', slug: 'name', name: 'Name', type: 'object', is_system: true, }, { id: 'first_name', slug: 'first_name', name: 'First Name', type: 'text', is_system: true, }, { id: 'last_name', slug: 'last_name', name: 'Last Name', type: 'text', is_system: true, }, { id: 'email_addresses', slug: 'email_addresses', name: 'Email Addresses', type: 'array', is_system: true, }, { id: 'phone_numbers', slug: 'phone_numbers', name: 'Phone Numbers', type: 'array', is_system: true, }, { id: 'job_title', slug: 'job_title', name: 'Job Title', type: 'text', is_system: true, }, { id: 'company', slug: 'company', name: 'Company', type: 'reference', is_system: true, }, ]; } /** * Get default attributes for deals */ private static getDefaultDealAttributes(): AttributeMetadata[] { return [ { id: 'name', slug: 'name', name: 'Deal Name', type: 'text', is_system: true, is_required: true, }, { id: 'value', slug: 'value', name: 'Value', type: 'currency', is_system: true, }, { id: 'stage', slug: 'stage', name: 'Stage', type: 'text', is_system: true, }, { id: 'close_date', slug: 'close_date', name: 'Close Date', type: 'date', is_system: true, }, { id: 'probability', slug: 'probability', name: 'Probability', type: 'number', is_system: true, }, { id: 'owner', slug: 'owner', name: 'Owner', type: 'reference', is_system: true, }, ]; } /** * Get default attributes for tasks */ private static getDefaultTaskAttributes(): AttributeMetadata[] { return [ { id: 'title', slug: 'title', name: 'Title', type: 'text', is_system: true, is_required: true, }, { id: 'description', slug: 'description', name: 'Description', type: 'text', is_system: true, }, { id: 'due_date', slug: 'due_date', name: 'Due Date', type: 'date', is_system: true, }, { id: 'status', slug: 'status', name: 'Status', type: 'text', is_system: true, }, { id: 'priority', slug: 'priority', name: 'Priority', type: 'text', is_system: true, }, { id: 'assignee', slug: 'assignee', name: 'Assignee', type: 'reference', is_system: true, }, ]; } /** * Get default generic attributes */ private static getDefaultAttributes(): AttributeMetadata[] { return [ { id: 'name', slug: 'name', name: 'Name', type: 'text', is_system: true, is_required: true, }, { id: 'description', slug: 'description', name: 'Description', type: 'text', is_system: true, }, { id: 'created_at', slug: 'created_at', name: 'Created At', type: 'datetime', is_system: true, }, { id: 'updated_at', slug: 'updated_at', name: 'Updated At', type: 'datetime', is_system: true, }, ]; } /** * Validate record data against available attributes */ static async validateRecordData( resourceType: UniversalResourceType, recordData: Record<string, unknown>, context?: { workspaceId?: string; tenantId?: string } ): Promise<{ isValid: boolean; valid: boolean; // Keep for backward compatibility errors: string[]; warnings: string[]; suggestions: Map<string, string>; }> { const errors: string[] = []; const warnings: string[] = []; const suggestions = new Map<string, string>(); // Get available attributes with context for multi-tenant support const attributes = await this.getAttributes(resourceType, context); // Filter out any malformed attributes (defensive) const safeAttributes = (attributes || []).filter( (a): a is AttributeMetadata => Boolean(a && typeof a.slug === 'string' && a.slug.length > 0) ); const attributeMap = new Map( safeAttributes.map((attr) => [attr.slug.toLowerCase(), attr]) ); // Also create a map of common variations const variationMap = new Map<string, string>(); for (const attr of safeAttributes) { // Add common variations variationMap.set(attr.slug.replace(/_/g, ''), attr.slug); variationMap.set(attr.slug.replace(/_/g, '-'), attr.slug); variationMap.set(attr.slug.replace(/-/g, '_'), attr.slug); // Add camelCase variations const camelCase = attr.slug.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase() ); variationMap.set(camelCase, attr.slug); // Add common alias mappings for known fields // Singular/plural variations if (attr.slug === 'domains') variationMap.set('domain', 'domains'); if (attr.slug === 'email_addresses') variationMap.set('email_address', 'email_addresses'); if (attr.slug === 'phone_numbers') { variationMap.set('phone', 'phone_numbers'); variationMap.set('phone_number', 'phone_numbers'); } } // Check each field in record data for (const [field, value] of Object.entries(recordData)) { const fieldLower = field.toLowerCase(); // Check if field exists if (!attributeMap.has(fieldLower)) { // Check for common variations const suggestion = variationMap.get(fieldLower) || this.findSimilarAttribute(field, attributes); if (suggestion) { warnings.push( `Field "${field}" might be misspelled. Did you mean "${suggestion}"?` ); suggestions.set(field, suggestion); } else { errors.push( `Unknown field: "${field}". This field does not exist for ${resourceType}.` ); } } else { // Validate field type const attr = attributeMap.get(fieldLower)!; const typeError = this.validateFieldType(field, value, attr); if (typeError) { errors.push(typeError); } // Check if field is writable if (attr.is_writable === false) { warnings.push(`Field "${field}" is read-only and will be ignored.`); } } } // Check for required fields const requiredFields = safeAttributes.filter((attr) => attr.is_required); for (const attr of requiredFields) { if ( !(attr.slug in recordData) && !(attr.slug.toLowerCase() in recordData) ) { errors.push(`Missing required field: "${attr.slug}"`); } } const isValid = errors.length === 0; return { isValid, valid: isValid, // Keep for backward compatibility errors, warnings, suggestions, }; } /** * Find similar attribute name using Levenshtein distance */ private static findSimilarAttribute( field: string, attributes: AttributeMetadata[] ): string | null { const fieldLower = field.toLowerCase(); let bestMatch: string | null = null; let bestDistance = Infinity; for (const attr of attributes) { const distance = this.levenshteinDistance( fieldLower, attr.slug.toLowerCase() ); // Consider it a match if distance is less than 3 (minor typo) if (distance < 3 && distance < bestDistance) { bestDistance = distance; bestMatch = attr.slug; } } return bestMatch; } /** * Calculate Levenshtein distance between two strings */ private static levenshteinDistance(str1: string, str2: string): number { const matrix: number[][] = []; for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[str2.length][str1.length]; } /** * Validate field type */ private static validateFieldType( field: string, value: unknown, attr: AttributeMetadata ): string | null { if (value === null || value === undefined) { return null; // Allow null/undefined } switch (attr.type) { case 'text': case 'string': if (typeof value !== 'string') { return `Field "${field}" expects a string, got ${typeof value}`; } break; case 'number': case 'integer': if (typeof value !== 'number') { return `Field "${field}" expects a number, got ${typeof value}`; } break; case 'boolean': if (typeof value !== 'boolean') { return `Field "${field}" expects a boolean, got ${typeof value}`; } break; case 'date': case 'datetime': // Accept string dates or Date objects if (typeof value !== 'string' && !(value instanceof Date)) { return `Field "${field}" expects a date string or Date object, got ${typeof value}`; } break; case 'array': if (!Array.isArray(value)) { return `Field "${field}" expects an array, got ${typeof value}`; } break; case 'object': if (typeof value !== 'object' || Array.isArray(value)) { return `Field "${field}" expects an object, got ${typeof value}`; } break; case 'currency': // Currency can be number or object with amount and currency if ( typeof value !== 'number' && (typeof value !== 'object' || !('amount' in (value as Record<string, unknown>))) ) { return `Field "${field}" expects a number or currency object, got ${typeof value}`; } break; } // Check allowed values if specified if (attr.allowed_values && attr.allowed_values.length > 0) { if (!(attr.allowed_values as unknown[]).includes(value)) { return `Field "${field}" must be one of: ${attr.allowed_values.join( ', ' )}`; } } return null; } /** * Clear attribute cache */ static clearCache(): void { attributeCache.clear(); } /** * Wrap create/update operations with pre-validation */ static async withPreValidation<T>( resourceType: UniversalResourceType, recordData: Record<string, unknown>, operation: () => Promise<T> ): Promise<T> { // Validate record data const validation = await this.validateRecordData(resourceType, recordData); // Log warnings if (validation.warnings.length > 0) { warn( 'utils/schema-pre-validation', 'Schema validation warnings detected', { warnings: validation.warnings }, 'validateRecordData', OperationType.VALIDATION ); } // Apply suggestions automatically if (validation.suggestions.size > 0) { const correctedData = { ...recordData }; for (const [wrong, correct] of Array.from(validation.suggestions)) { if (wrong in correctedData) { correctedData[correct] = correctedData[wrong]; delete correctedData[wrong]; } } // Update the original record data reference Object.keys(recordData).forEach((key) => delete recordData[key]); Object.assign(recordData, correctedData); } // Throw error if validation failed if (!validation.isValid) { throw new UniversalValidationError( `Schema validation failed:\n${validation.errors.join('\n')}`, ErrorType.USER_ERROR, { httpStatusCode: HttpStatusCode.UNPROCESSABLE_ENTITY, suggestion: 'Check the field names and types against the resource schema', } ); } // Execute the operation return await operation(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server