/**
* Tool: create_person
* Create a new person in Attio CRM
*/
import type { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { createAttioClient } from '../attio-client.js';
import { handleSearchPeople } from './search-people.js';
import {
handleToolError,
createSuccessResponse,
} from '../utils/error-handler.js';
import { ConfigurationError, ValidationError } from '../utils/errors.js';
import { ensureTagOptions } from '../utils/ensure-tag-option.js';
interface AttioPersonCreateResponse {
data: {
id: {
workspace_id: string;
object_id: string;
record_id: string;
};
created_at: string;
web_url: string;
values: {
name?: Array<{
first_name?: string;
last_name?: string;
full_name?: string;
}>;
email_addresses?: Array<{ email_address: string }>;
description?: Array<{ value: string }>;
linkedin?: Array<{ value: string }>;
tags?: Array<{ option?: { title: string } }>;
[key: string]: unknown;
};
};
}
/**
* Tool definition for MCP
*/
export const createPersonTool: Tool = {
name: 'create_person',
description:
'Create a new person in Attio CRM. You must provide at least one name field (first_name, last_name, or full_name) or at least one email address. All fields are optional, but you need either a name or email to identify the person. Returns the created person record including the new record_id.',
inputSchema: {
type: 'object',
properties: {
first_name: {
type: 'string',
description: 'Person first name (use with last_name)',
},
last_name: {
type: 'string',
description: 'Person last name (use with first_name)',
},
full_name: {
type: 'string',
description: 'Person full name (alternative to first_name/last_name)',
},
email_addresses: {
type: 'array',
items: { type: 'string' },
description:
'Email addresses (e.g., ["john@example.com", "john.doe@work.com"])',
},
description: {
type: 'string',
description: 'Person description or notes',
},
linkedin: {
type: 'string',
description: 'LinkedIn URL (e.g., "https://linkedin.com/in/johndoe")',
},
tags: {
type: 'array',
items: { type: 'string' },
description:
'Tags to apply to the person (e.g., ["Stanford", "Founder"]). New tags are auto-created if they don\'t exist.',
},
},
required: [],
},
};
/**
* Handler function for create_person tool
*/
export async function handleCreatePerson(args: {
first_name?: string;
last_name?: string;
full_name?: string;
email_addresses?: string[];
description?: string;
linkedin?: string;
tags?: string[];
}): Promise<CallToolResult> {
// Extract args at function level so they're available in catch block
const {
first_name,
last_name,
full_name,
email_addresses,
description,
linkedin,
tags,
} = args;
try {
const apiKey = process.env['ATTIO_API_KEY'];
if (!apiKey) {
throw new ConfigurationError('ATTIO_API_KEY not configured');
}
// Validate that at least one identifying field is provided
const hasName = !!(first_name || last_name || full_name);
// Filter empty emails before checking
const validEmails =
email_addresses?.filter((e) => e && e.trim().length > 0) || [];
const hasEmail = validEmails.length > 0;
if (!hasName && !hasEmail) {
throw new ValidationError(
'At least one name field (first_name, last_name, or full_name) or email address is required'
);
}
const client = createAttioClient(apiKey);
// Build request body in Attio format
const values: Record<string, unknown[]> = {};
// Build name object (only include if at least one name field is provided)
// Attio requires full_name field if name object is present
if (first_name || last_name || full_name) {
let parsedFirstName = first_name?.trim() || '';
let parsedLastName = last_name?.trim() || '';
const parsedFullName = full_name?.trim() || '';
// IMPROVEMENT 1: Parse full_name into first_name/last_name if not provided
// This prevents empty first_name/last_name when only full_name is given
if (parsedFullName && !parsedFirstName && !parsedLastName) {
const nameParts = parsedFullName.trim().split(/\s+/);
parsedFirstName = nameParts[0] || '';
parsedLastName = nameParts.slice(1).join(' ') || '';
}
const nameObj: {
first_name: string;
last_name: string;
full_name: string;
} = {
first_name: parsedFirstName,
last_name: parsedLastName,
full_name: parsedFullName,
};
// If full_name wasn't provided, construct it from first_name and last_name
if (!nameObj.full_name && (nameObj.first_name || nameObj.last_name)) {
nameObj.full_name = [nameObj.first_name, nameObj.last_name]
.filter(Boolean)
.join(' ');
}
values['name'] = [nameObj];
}
// Add optional fields if provided
if (validEmails.length > 0) {
values['email_addresses'] = validEmails.map((email) => ({
email_address: email.trim(),
}));
}
if (description && description.trim().length > 0) {
values['description'] = [{ value: description.trim() }];
}
if (linkedin && linkedin.trim().length > 0) {
values['linkedin'] = [{ value: linkedin.trim() }];
}
// Add tags if provided
if (tags && tags.length > 0) {
const validTags = tags
.filter((t) => t && t.trim().length > 0)
.map((t) => t.trim());
if (validTags.length > 0) {
// Ensure tags exist in workspace (auto-create if missing)
await ensureTagOptions(client, 'people', 'tags', validTags);
values['tags'] = validTags;
}
}
// Create person via Attio API
const response = await client.post<AttioPersonCreateResponse>(
'/objects/people/records',
{
data: {
values,
},
}
);
const person = response.data;
const nameData = person.values.name?.[0];
// Transform to clean format
const result = {
record_id: person.id.record_id,
workspace_id: person.id.workspace_id,
object_id: person.id.object_id,
created_at: person.created_at,
web_url: person.web_url,
name: nameData?.full_name || null,
first_name: nameData?.first_name || null,
last_name: nameData?.last_name || null,
email_addresses:
person.values.email_addresses
?.map((e) => e.email_address)
.filter(Boolean) || [],
description: person.values.description?.[0]?.value || null,
linkedin: person.values.linkedin?.[0]?.value || null,
tags:
person.values.tags
?.map((t) => t.option?.title)
.filter((t): t is string => !!t) || [],
};
return createSuccessResponse(result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
// IMPROVEMENT 2: Handle uniqueness constraint errors gracefully
// If email already exists, search for and return the existing person
if (
errorMessage.includes('uniqueness constraint') &&
email_addresses &&
email_addresses.length > 0
) {
const duplicateEmail = email_addresses[0];
if (!duplicateEmail) {
throw new Error(`Failed to create person: ${errorMessage}`);
}
try {
// Search for existing person by email
const searchResult = await handleSearchPeople({
query: duplicateEmail,
limit: 1,
});
const content = searchResult.content[0];
const searchData = JSON.parse(
content && 'text' in content ? content.text : '{}'
);
if (searchData.count > 0) {
const existingPerson = searchData.people[0];
// Return existing person with a status indicator
// Note: This is a success response (idempotent behavior)
const result = {
status: 'already_exists',
message: `Person with email "${duplicateEmail}" already exists in the system`,
record_id: existingPerson.record_id,
name: existingPerson.name,
first_name: existingPerson.first_name,
last_name: existingPerson.last_name,
email_addresses: existingPerson.email_addresses,
description: existingPerson.description,
linkedin: existingPerson.linkedin,
};
return createSuccessResponse(result);
}
} catch (searchError) {
// If search fails, fall through to original error
console.error('Failed to search for existing person:', searchError);
}
}
// Use structured error handler for all other errors
return handleToolError(error, 'create_person');
}
}