/**
* Tool: search_people
* Search for people with sophisticated filtering
*/
import type { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { createAttioClient } from '../attio-client.js';
import {
handleToolError,
createSuccessResponse,
} from '../utils/error-handler.js';
import { ConfigurationError } from '../utils/errors.js';
interface AttioPersonRecord {
id: {
workspace_id: string;
object_id: string;
record_id: string;
};
created_at?: 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 }>;
company?: Array<{
target_object: string;
target_record_id: string;
}>;
tags?: Array<{
option?: {
title: string;
};
title?: string;
}>;
};
}
interface AttioPeopleResponse {
data: AttioPersonRecord[];
}
/**
* Tool definition for MCP
*/
export const searchPeopleTool: Tool = {
name: 'search_people',
description:
'Search for people in Attio CRM. Supports filtering by text (name or email), company, tags, and date ranges. Supports sorting by various fields. All filters are optional. Returns person details including name, email addresses, description, LinkedIn URL, tags, web_url, and created_at.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'Text search in person name or email (e.g., "john", "smith", "john@acme.com"). Case-insensitive partial matching.',
},
company_id: {
type: 'string',
description:
'Filter by company record_id (from search_companies or get_company). Returns people associated with this company.',
},
tags: {
type: 'array',
items: {
type: 'string',
},
description:
'Filter people containing ANY of these tags (OR logic within tags). Example: ["VIP", "Investor"] matches people with VIP OR Investor tags.',
},
tags_exclude: {
type: 'array',
items: {
type: 'string',
},
description:
'Filter people NOT containing ANY of these tags (NOR logic within tags). Example: ["Inactive", "Archived"] excludes people with Inactive OR Archived tags.',
},
created_after: {
type: 'string',
description:
'Filter by creation date - only records created after this date (ISO 8601 format, e.g., "2024-01-01" or "2024-01-01T00:00:00Z").',
},
created_before: {
type: 'string',
description:
'Filter by creation date - only records created before this date (ISO 8601 format, e.g., "2024-12-31" or "2024-12-31T23:59:59Z").',
},
sort_by: {
type: 'string',
description: 'Field to sort results by. Options: "created_at", "name".',
enum: ['created_at', 'name'],
},
sort_direction: {
type: 'string',
description:
'Sort direction. Options: "asc" (ascending), "desc" (descending). Default: "desc".',
enum: ['asc', 'desc'],
default: 'desc',
},
limit: {
type: 'number',
description:
'Maximum number of results to return (default: 50, max: 500)',
default: 50,
minimum: 1,
maximum: 500,
},
},
required: [],
},
};
/**
* Handler function for search_people tool
*/
export async function handleSearchPeople(args: {
query?: string;
company_id?: string;
tags?: string[];
tags_exclude?: string[];
created_after?: string;
created_before?: string;
sort_by?: string;
sort_direction?: string;
limit?: number;
}): Promise<CallToolResult> {
try {
const apiKey = process.env['ATTIO_API_KEY'];
if (!apiKey) {
throw new ConfigurationError('ATTIO_API_KEY not configured');
}
const {
query,
company_id,
tags,
tags_exclude,
created_after,
created_before,
sort_by,
sort_direction = 'desc',
limit = 50,
} = args;
// Validate and cap limit
const validatedLimit = Math.min(Math.max(1, limit), 500);
const client = createAttioClient(apiKey);
// Build filter conditions
const conditions: Array<Record<string, unknown>> = [];
// Text search in name or email
if (query && query.trim().length > 0) {
conditions.push({
$or: [
{ name: { first_name: { $contains: query.trim() } } },
{ name: { last_name: { $contains: query.trim() } } },
{ name: { full_name: { $contains: query.trim() } } },
{ email_addresses: { email_address: { $contains: query.trim() } } },
],
});
}
// Company relationship filter
if (company_id && company_id.trim().length > 0) {
conditions.push({
company: {
target_record_id: company_id.trim(),
},
});
}
// Tags filter (OR logic - any of these tags)
if (tags && tags.length > 0) {
const validTags = tags
.filter((t) => t && t.trim().length > 0)
.map((t) => t.trim());
if (validTags.length > 0) {
if (validTags.length === 1) {
conditions.push({
tags: validTags[0],
});
} else {
conditions.push({
$or: validTags.map((tag) => ({ tags: tag })),
});
}
}
}
// Tags exclude filter (NOR logic - none of these tags)
if (tags_exclude && tags_exclude.length > 0) {
const validExcludeTags = tags_exclude
.filter((t) => t && t.trim().length > 0)
.map((t) => t.trim());
if (validExcludeTags.length > 0) {
validExcludeTags.forEach((tag) => {
conditions.push({
tags: { $not: tag },
});
});
}
}
// Date range filters
if (created_after && created_after.trim().length > 0) {
conditions.push({
created_at: { $gte: created_after.trim() },
});
}
if (created_before && created_before.trim().length > 0) {
conditions.push({
created_at: { $lte: created_before.trim() },
});
}
// Build final filter (omit filter key if no conditions to get all records)
const filter =
conditions.length === 0
? undefined
: conditions.length === 1
? conditions[0]
: { $and: conditions };
// Query Attio API
const requestBody: {
limit: number;
filter?: Record<string, unknown>;
sorts?: Array<{ attribute: string; direction: string }>;
} = {
limit: validatedLimit,
};
// Only include filter if we have conditions
if (filter) {
requestBody.filter = filter;
}
// Add sorting if specified
if (sort_by && sort_by.trim().length > 0) {
requestBody.sorts = [
{
attribute: sort_by.trim(),
direction: sort_direction === 'asc' ? 'asc' : 'desc',
},
];
}
const response = await client.post<AttioPeopleResponse>(
'/objects/people/records/query',
requestBody
);
const people = response.data || [];
// Transform to clean format
const results = people.map((person) => {
const nameObj = person.values.name?.[0];
return {
record_id: person.id.record_id,
web_url: `https://app.attio.com/${process.env['ATTIO_WORKSPACE_SLUG'] || 'workspace'}/people/${person.id.record_id}`,
name: nameObj?.full_name || null,
first_name: nameObj?.first_name || null,
last_name: nameObj?.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,
company_id: person.values.company?.[0]?.target_record_id || null,
tags:
person.values.tags
?.map((t: any) => t.option?.title || t.title)
.filter(Boolean) || [],
created_at: person.created_at || null,
};
});
const result = {
filters: {
query: query || null,
company_id: company_id || null,
tags: tags || null,
tags_exclude: tags_exclude || null,
created_after: created_after || null,
created_before: created_before || null,
},
sort: sort_by ? { by: sort_by, direction: sort_direction } : null,
limit: validatedLimit,
count: results.length,
has_more: results.length === validatedLimit,
people: results,
};
return createSuccessResponse(result);
} catch (error) {
return handleToolError(error, 'search_people');
}
}