Skip to main content
Glama
person-creator.tsβ€’9.27 kB
/** * PersonCreator - Strategy implementation for person resource creation * * Handles person-specific creation logic including name and email normalization, * email retry logic, error recovery, and person record processing. */ import type { AxiosResponse } from 'axios'; import type { AttioRecord, JsonObject } from '@shared-types/attio.js'; import type { ResourceCreatorContext, RecoveryOptions } from './types.js'; import { BaseCreator } from './base-creator.js'; import { normalizePersonValues } from '../data-normalizers.js'; import { EmailRetryManager } from './email-strategies.js'; import { extractAttioRecord, assertLooksLikeCreated, isTestRun, debugRecordShape, normalizeRecordForOutput, } from '../extractor.js'; import { registerMockAliasIfPresent } from '../../../test-support/mock-alias.js'; import { createScopedLogger } from '../../../utils/logger.js'; import { safeExtractRecordId } from '../../../utils/type-extraction.js'; /** * Person-specific resource creator * Implements Strategy Pattern for person creation with email retry logic */ export class PersonCreator extends BaseCreator { readonly resourceType = 'people'; readonly endpoint = '/objects/people/records'; private emailRetryManager = new EmailRetryManager(); /** * Creates a person record with name and email normalization * * @param input - Person data including name, email/email_addresses, title, etc. * @param context - Shared context with client and utilities * @returns Promise<AttioRecord> - Created person record with id.record_id */ async create( input: JsonObject, context: ResourceCreatorContext ): Promise<AttioRecord> { this.assertClientHasAuth(context); const normalizedPerson = this.normalizeInput(input); // Validate that at least a name is provided (per Attio API spec: only 'name' is required) // Reference: https://docs.attio.com/docs/standard-objects/standard-objects-people // The Attio People API requires only 'name' field; email_addresses is optional if (!normalizedPerson.name) { throw new Error('missing required parameter: name'); } context.debug(this.constructor.name, 'πŸ” EXACT API PAYLOAD', { url: this.endpoint, payload: JSON.stringify({ data: { values: normalizedPerson } }, null, 2), } as JsonObject); try { const response = await this.createPersonWithRetry( context, normalizedPerson ); const rec = this.extractRecordFromResponse( response as unknown as JsonObject ); this.finalizeRecord(rec, context); const recordId = safeExtractRecordId(rec); if (recordId) { registerMockAliasIfPresent(input, recordId); } const out = normalizeRecordForOutput(rec, 'people'); // Optional debug to confirm the shape: if (process.env.MCP_LOG_LEVEL === 'DEBUG') { createScopedLogger('PersonCreator', 'create').debug('types', { nameBefore: Array.isArray( ((rec as JsonObject)?.values as JsonObject)?.name ) ? 'array' : typeof ((rec as JsonObject)?.values as JsonObject)?.name, nameAfter: Array.isArray( ((out as JsonObject)?.values as JsonObject)?.name ) ? 'array' : typeof ((out as JsonObject)?.values as JsonObject)?.name, }); } return out as AttioRecord; } catch (err: unknown) { return this.handleApiError(err, context, { data: { values: normalizedPerson }, } as JsonObject); } } /** * Normalizes person input data * Handles name and email field normalization */ protected normalizeInput(input: JsonObject): JsonObject { return normalizePersonValues(input); } /** * Creates person with email format retry logic using strategy pattern * Attempts alternative email formats on 400 errors */ private async createPersonWithRetry( context: ResourceCreatorContext, filteredPersonData: JsonObject ): Promise<AxiosResponse> { const doCreate = async (values: JsonObject) => context.client.post(this.endpoint, { data: { values } }); try { // Attempt #1: Try original format return await doCreate(filteredPersonData); } catch (firstErr: unknown) { const error = firstErr as { response?: { status?: number } }; const status = error?.response?.status; // Only retry on 400 (validation error) with alternate email schema if (status === 400) { const retryResult = this.emailRetryManager.tryConvertEmailFormat(filteredPersonData); if (retryResult) { context.debug( this.constructor.name, 'Retrying person creation with alternate email format', { originalFormat: retryResult.originalFormat, retryFormat: retryResult.alternativeFormat, } as JsonObject ); return await doCreate(retryResult.convertedData); } } throw firstErr; } } /** * Provides person-specific recovery options * Attempts recovery by primary email address */ protected getRecoveryOptions(): RecoveryOptions { return { searchFilters: [ { field: 'email_addresses', value: '', // Will be set dynamically in attemptRecovery operator: 'contains', }, ], maxAttempts: 1, }; } /** * Person-specific recovery implementation * Attempts to find person by primary email address */ protected async attemptRecovery( context: ResourceCreatorContext, normalizedInput?: JsonObject ): Promise<AttioRecord> { if (!normalizedInput) { throw this.createEnhancedError( new Error('Person creation returned empty/invalid record'), context, 500 ); } // Try recovery by primary email const email = Array.isArray(normalizedInput.email_addresses) ? (normalizedInput.email_addresses[0] as string) : undefined; try { if (email) { const { data: searchResult } = await context.client.post( `${this.endpoint}/search`, { filter: { email_addresses: { contains: email } }, limit: 1, order: { created_at: 'desc' }, } ); const record = this.extractRecordFromSearch(searchResult); if ((record as JsonObject)?.id && safeExtractRecordId(record)) { context.debug( this.constructor.name, 'Person recovery succeeded by email', { email, recordId: ((record as JsonObject)?.id as JsonObject)?.record_id, } as JsonObject ); return record as AttioRecord; } } } catch (e) { context.debug(this.constructor.name, 'Person recovery failed', { message: (e as Error)?.message, } as JsonObject); } throw this.createEnhancedError( new Error('Person creation and recovery both failed'), context, 500 ); } /** * Processes response with person-specific logic * Includes recovery attempt with normalized input */ protected async processResponse( response: JsonObject, context: ResourceCreatorContext, normalizedInput?: JsonObject ): Promise<AttioRecord> { context.debug(this.constructor.name, `${this.resourceType} API response`, { status: response?.status, statusText: response?.statusText, hasData: !!response?.data, hasNestedData: !!(response?.data as JsonObject)?.data, } as JsonObject); let record = this.extractRecordFromResponse(response); record = this.enrichRecordId(record, response); // Handle empty response with recovery attempt const mustRecover = !record || !(record as JsonObject).id || !safeExtractRecordId(record); if (mustRecover && normalizedInput) { record = await this.attemptRecovery(context, normalizedInput); } return this.finalizeRecord(record, context); } /** * Extracts record from API response */ private extractRecordFromResponse(response: JsonObject): JsonObject { return extractAttioRecord(response) || ({} as JsonObject); } /** * Extracts record from search results */ private extractRecordFromSearch(searchData: JsonObject): JsonObject { return extractAttioRecord(searchData) || ({} as JsonObject); } /** * Finalizes record processing */ private finalizeRecord( record: JsonObject, context: ResourceCreatorContext ): AttioRecord { assertLooksLikeCreated(record, `${this.constructor.name}.create`); /* istanbul ignore next */ if (process.env.MCP_LOG_LEVEL === 'DEBUG') { createScopedLogger('PersonCreator', 'finalizeRecord').debug( 'extracted keys', { keys: record && typeof record === 'object' ? Object.keys(record) : [typeof record], } as JsonObject ); } if (isTestRun()) { context.debug( this.constructor.name, `Normalized ${this.resourceType} record`, debugRecordShape(record) ); } return record as AttioRecord; } }

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