Skip to main content
Glama
handlers.ts28.8 kB
/** * Tool handlers for Attio MCP - edge-compatible implementations * * These handlers use the HttpClient interface and work in any JavaScript runtime * (Node.js, Cloudflare Workers, Deno, browsers with CORS) */ import type { HttpClient } from '../api/http-client.js'; import type { ResourceType, ToolResult, AttioRecord, AttioApiResponse, AttioFilterConfig, AttioNote, ToolHandlerConfig, } from '../types/index.js'; import { normalizePhoneForAttio, PhoneValidationError, } from '../utils/phone-validation.js'; /** * Create a successful tool result */ function successResult(text: string): ToolResult { return { content: [{ type: 'text', text }], isError: false, }; } /** * Create a structured tool result that includes both JSON data and a human-readable summary. */ function structuredResult(data: unknown, summaryText?: string): ToolResult { const fallbackText = summaryText ?? (typeof data === 'string' ? data : (() => { try { return JSON.stringify(data, null, 2); } catch { return String(data); } })()); return { content: [ { type: 'json', data }, { type: 'text', text: fallbackText }, ], isError: false, }; } /** * Create an error tool result */ function errorResult(message: string, details?: unknown): ToolResult { let text = `Error: ${message}`; if (details) { text += `\n\nDetails: ${JSON.stringify(details, null, 2)}`; } return { content: [{ type: 'text', text }], isError: true, }; } /** * Extract error message from various error shapes */ function extractErrorInfo(error: unknown): { message: string; details?: unknown; } { // HttpError from our client: { status, message, details } if (error && typeof error === 'object' && 'message' in error) { const err = error as { message: string; details?: unknown; status?: number; }; return { message: err.status ? `[${err.status}] ${err.message}` : err.message, details: err.details, }; } // Standard Error if (error instanceof Error) { return { message: error.message }; } return { message: 'Unknown error' }; } /** * Extract display name from a record */ function extractDisplayName(record: AttioRecord): string { const values = record.values; // Try common name fields for (const field of ['name', 'full_name', 'title', 'company_name']) { const fieldValue = values[field]; if (Array.isArray(fieldValue) && fieldValue.length > 0) { const first = fieldValue[0]; if (first?.value && typeof first.value === 'string') { return first.value; } if (first?.full_name && typeof first.full_name === 'string') { return first.full_name; } } } // Try email as fallback const emails = values.email_addresses; if (Array.isArray(emails) && emails.length > 0 && emails[0]?.email_address) { return String(emails[0].email_address); } return 'Unnamed'; } /** * Format a list of records for display */ function formatRecordList( records: AttioRecord[], resourceType: string ): string { if (!records || records.length === 0) { return `No ${resourceType} found`; } const lines = records.map((record) => { const name = extractDisplayName(record); const id = record.id?.record_id || 'unknown'; return `- ${name} (ID: ${id})`; }); return `Found ${records.length} ${resourceType}:\n${lines.join('\n')}`; } /** * Format a single record for display */ function formatRecordDetails( record: AttioRecord, resourceType: string ): string { const name = extractDisplayName(record); const id = record.id?.record_id || 'unknown'; const lines: string[] = [`${resourceType}: ${name}`, `ID: ${id}`]; // Add key fields const values = record.values; for (const [key, fieldValue] of Object.entries(values)) { if (!Array.isArray(fieldValue) || fieldValue.length === 0) continue; const first = fieldValue[0]; let displayValue: string | undefined; if (first?.value !== undefined && first.value !== null) { displayValue = String(first.value); } else if (first?.email_address) { displayValue = String(first.email_address); } else if (first?.phone_number) { displayValue = String(first.phone_number); } else if (first?.full_name) { displayValue = String(first.full_name); } if (displayValue && displayValue !== 'undefined') { lines.push(`${key}: ${displayValue}`); } } return lines.join('\n'); } /** * Map resource type to Attio object slug */ function getObjectSlug(resourceType: string): string { const mapping: Record<string, string> = { companies: 'companies', people: 'people', deals: 'deals', users: 'users', workspaces: 'workspaces', }; return mapping[resourceType] || resourceType; } /** * Special field types that require specific value structures */ const SPECIAL_FIELD_FORMATS: Record<string, string> = { domains: 'domain', email_addresses: 'email_address', phone_numbers: 'original_phone_number', }; /** * Transform user-provided record data into Attio's expected format * * Attio requires: * 1. All values wrapped in a `values` object * 2. Each field value as an array: [{value: "..."}] or [{domain: "..."}] etc. * * Phone numbers are validated and normalized to E.164 format before sending. * * @param recordData - User-provided record data * @param config - Optional configuration (e.g., default country for phone numbers) * @throws PhoneValidationError if phone numbers are invalid */ function transformRecordData( recordData: Record<string, unknown>, config?: ToolHandlerConfig ): Record<string, unknown> { const values: Record<string, unknown> = {}; for (const [key, value] of Object.entries(recordData)) { if (value === undefined || value === null) continue; // Special handling for phone_numbers with validation and E.164 normalization if (key === 'phone_numbers') { const phoneArray = Array.isArray(value) ? value : [value]; const normalizedPhones: Record<string, unknown>[] = []; for (const phone of phoneArray) { if (phone === undefined || phone === null) continue; // Skip if already in Attio format with original_phone_number if ( typeof phone === 'object' && phone !== null && 'original_phone_number' in phone && !('phone_number' in phone) && !('phone' in phone) ) { normalizedPhones.push(phone as Record<string, unknown>); continue; } // Normalize and validate phone number const normalized = normalizePhoneForAttio( phone as string | Record<string, unknown>, { defaultCountry: config?.phone?.defaultCountry } ); normalizedPhones.push(normalized); } if (normalizedPhones.length > 0) { values[key] = normalizedPhones; } continue; } // If value is already in Attio array format [{...}], pass through if ( Array.isArray(value) && value.length > 0 && typeof value[0] === 'object' ) { values[key] = value; continue; } // Check if this is a special field type (domains, email_addresses) const specialKey = SPECIAL_FIELD_FORMATS[key]; if (specialKey) { // Handle array of simple values (e.g., multiple domains) if (Array.isArray(value)) { values[key] = value.map((v) => ({ [specialKey]: String(v) })); } else { values[key] = [{ [specialKey]: String(value) }]; } continue; } // Standard field: wrap in [{value: ...}] if (Array.isArray(value)) { // Array of simple values values[key] = value.map((v) => ({ value: v })); } else { values[key] = [{ value }]; } } return { values }; } /** * Health check handler - no API calls required */ export async function handleHealthCheck(params: { echo?: string; }): Promise<ToolResult> { const payload = { ok: true, name: 'attio-mcp-core', version: '0.1.0', echo: params.echo, timestamp: new Date().toISOString(), runtime: typeof (globalThis as Record<string, unknown>).Deno !== 'undefined' ? 'deno' : typeof (globalThis as Record<string, unknown>).Bun !== 'undefined' ? 'bun' : typeof (globalThis as Record<string, unknown>).caches !== 'undefined' ? 'cloudflare-workers' : 'node', }; return successResult(JSON.stringify(payload, null, 2)); } /** * Build search filter based on resource type * Different objects have different name field structures */ function buildSearchFilter( resourceType: string, query: string ): Record<string, unknown> { // For people, 'name' is a personal-name type with 'full_name' sub-property if (resourceType === 'people') { return { $or: [ { name: { full_name: { $contains: query } } }, { email_addresses: { email_address: { $contains: query } } }, ], }; } // For companies and other objects, 'name' is a plain text field // Use shorthand filter which does contains match return { name: { $contains: query }, }; } /** * Known actor-reference field slugs that require special filter format. * Actor-reference fields cannot use standard operators like $eq. * Instead, they require: { referenced_actor_type: "workspace-member", referenced_actor_id: "..." } */ const ACTOR_REFERENCE_FIELDS = new Set([ 'owner', 'created_by', 'assigned_to', 'assignee', 'created_by_actor', 'updated_by', ]); /** * Known status field slugs that only support equality operators. * Status fields (pipeline stages, statuses) cannot use $contains, $starts_with, etc. * They only support: $eq */ const STATUS_FIELDS = new Set([ 'stage', 'status', 'deal_stage', 'pipeline_stage', ]); /** * Map common operator aliases to Attio API operators. * Users may use "equals" but Attio expects "$eq". */ const OPERATOR_ALIASES: Record<string, string> = { // Equality aliases equals: 'eq', is: 'eq', equal: 'eq', // Inequality aliases not_equals: 'ne', not_equal: 'ne', neq: 'ne', isnt: 'ne', // Comparison aliases greater_than: 'gt', greater: 'gt', less_than: 'lt', less: 'lt', greater_than_or_equals: 'gte', greater_or_equal: 'gte', less_than_or_equals: 'lte', less_or_equal: 'lte', // Text aliases includes: 'contains', has: 'contains', like: 'contains', // Empty/exists aliases is_empty: 'empty', is_not_empty: 'not_empty', has_value: 'not_empty', }; /** * Normalize an operator to Attio's expected format. * - Strips leading $ if present * - Maps common aliases to valid operators */ function normalizeOperator(condition: string): string { // Strip leading $ if present (user may have included it) let normalized = condition.startsWith('$') ? condition.slice(1) : condition; // Convert to lowercase for consistent matching normalized = normalized.toLowerCase(); // Check if this is an alias that needs mapping if (OPERATOR_ALIASES[normalized]) { normalized = OPERATOR_ALIASES[normalized]; } return normalized; } /** * Transform a filter condition to Attio's expected format. * Handles special cases for actor-reference fields and status fields. * Normalizes operator aliases (equals→eq, is→eq, $eq→eq, etc.) */ function transformFilterCondition( slug: string, condition: string, value: unknown ): Record<string, unknown> { // Check if this is an actor-reference field if (ACTOR_REFERENCE_FIELDS.has(slug)) { // Actor-reference fields require a special format // They don't support $eq - instead use the direct object format return { [slug]: { referenced_actor_type: 'workspace-member', referenced_actor_id: String(value), }, }; } // Normalize the operator (strip $, map aliases like equals→eq) const normalizedOp = normalizeOperator(condition); // Check if this is a status field with invalid operator if (STATUS_FIELDS.has(slug) && normalizedOp !== 'eq') { // Throw with helpful message - will be caught and returned as tool error throw new Error( `Status field "${slug}" only supports equality operators (eq/equals). ` + `Use condition: "eq" with exact stage value. ` + `Got: "${condition}"` ); } // Standard filter format with normalized operator return { [slug]: { [`$${normalizedOp}`]: value }, }; } /** * Search records handler * Routes to appropriate API based on resource type: * - tasks: /v2/tasks (GET with query params) * - workspace_members: /v2/workspace_members (GET) * - objects: /v2/objects/{slug}/records/query (POST) */ export async function handleSearchRecords( client: HttpClient, params: { resource_type: ResourceType; query?: string; filters?: AttioFilterConfig; limit?: number; offset?: number; } ): Promise<ToolResult> { try { const { resource_type, query, filters, limit = 10, offset = 0 } = params; // Special routing for tasks - uses /v2/tasks endpoint if (resource_type === 'tasks') { return await handleSearchTasks(client, { query, limit, offset }); } // Special routing for workspace_members - uses /v2/workspace_members endpoint if (resource_type === 'workspace_members') { return await handleListWorkspaceMembers(client); } const objectSlug = getObjectSlug(resource_type); // Build request body for objects API const body: Record<string, unknown> = { limit, offset, }; if (query) { body.filter = buildSearchFilter(resource_type, query); } if (filters?.filters && filters.filters.length > 0) { // Convert our filter format to Attio's format // Special handling for actor-reference fields (owner, created_by, assigned_to, etc.) // and status fields (stage, status) that only support equality operators try { const attioFilters = filters.filters.map((f) => transformFilterCondition(f.attribute.slug, f.condition, f.value) ); if (filters.matchAny) { body.filter = { $or: attioFilters }; } else { body.filter = { $and: attioFilters }; } } catch (validationError) { // Return validation error as tool result before hitting API return errorResult( validationError instanceof Error ? validationError.message : 'Filter validation error', { hint: 'Use records_discover_attributes to check field types' } ); } } const response = await client.post<AttioApiResponse<AttioRecord[]>>( `/v2/objects/${objectSlug}/records/query`, body ); const records = response.data.data || []; const summary = formatRecordList(records, resource_type); return structuredResult(records, summary); } catch (error) { const { message, details } = extractErrorInfo(error); return errorResult(message || 'Search failed', details); } } /** * Search tasks using dedicated /v2/tasks endpoint */ async function handleSearchTasks( client: HttpClient, params: { query?: string; limit?: number; offset?: number } ): Promise<ToolResult> { const { limit = 10, offset = 0 } = params; // Tasks API uses GET with query params const queryParams = new URLSearchParams({ limit: String(limit), offset: String(offset), }); const response = await client.get<{ data: Array<{ id: { task_id: string }; content_plaintext?: string; deadline_at?: string; is_completed?: boolean; assignees?: Array<{ referenced_actor_id: string }>; created_at?: string; }>; }>(`/v2/tasks?${queryParams.toString()}`); const tasks = response.data.data || []; if (tasks.length === 0) { return structuredResult([], 'No tasks found'); } const normalizedTasks = tasks.map((task) => ({ ...task, id: { ...task.id, workspace_id: (task as any).id?.workspace_id ?? 'unknown', }, })); const lines = [`Found ${tasks.length} tasks:`]; for (const task of normalizedTasks) { const status = task.is_completed ? '✓' : '○'; const content = task.content_plaintext || 'No content'; const deadline = task.deadline_at ? ` (due: ${task.deadline_at.split('T')[0]})` : ''; lines.push(`${status} ${content}${deadline} (ID: ${task.id.task_id})`); } return structuredResult(normalizedTasks, lines.join('\n')); } /** * List workspace members using dedicated /v2/workspace_members endpoint */ async function handleListWorkspaceMembers( client: HttpClient ): Promise<ToolResult> { const response = await client.get<{ data: Array<{ id: { workspace_member_id: string }; first_name?: string; last_name?: string; email_address?: string; access_level?: string; }>; }>('/v2/workspace_members'); const members = response.data.data || []; if (members.length === 0) { return structuredResult([], 'No workspace members found'); } const lines = [`Found ${members.length} workspace members:`]; for (const member of members) { const name = [member.first_name, member.last_name].filter(Boolean).join(' ') || 'Unknown'; const email = member.email_address ? ` (${member.email_address})` : ''; const role = member.access_level ? ` - ${member.access_level}` : ''; lines.push( `- ${name}${email}${role} (ID: ${member.id.workspace_member_id})` ); } return structuredResult(members, lines.join('\n')); } /** * Get record details handler */ export async function handleGetRecordDetails( client: HttpClient, params: { resource_type: ResourceType; record_id: string; fields?: string[]; } ): Promise<ToolResult> { try { const { resource_type, record_id } = params; const objectSlug = getObjectSlug(resource_type); const response = await client.get<AttioApiResponse<AttioRecord>>( `/v2/objects/${objectSlug}/records/${record_id}` ); const record = response.data.data; return structuredResult(record, formatRecordDetails(record, resource_type)); } catch (error) { const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to get record', details); } } /** * Create record handler * * @param client - HTTP client for API requests * @param params - Record creation parameters * @param config - Optional configuration (e.g., default country for phone numbers) */ export async function handleCreateRecord( client: HttpClient, params: { resource_type: ResourceType; record_data: Record<string, unknown>; return_details?: boolean; }, config?: ToolHandlerConfig ): Promise<ToolResult> { try { const { resource_type, record_data, return_details = true } = params; const objectSlug = getObjectSlug(resource_type); // Transform record_data to Attio's expected format with values wrapper // Phone numbers are validated and normalized to E.164 format const data = transformRecordData(record_data, config); const response = await client.post<AttioApiResponse<AttioRecord>>( `/v2/objects/${objectSlug}/records`, { data } ); const record = response.data.data; const id = record.id?.record_id || 'unknown'; if (return_details) { return structuredResult( record, `Created ${resource_type} record:\n${formatRecordDetails(record, resource_type)}` ); } return structuredResult( record, `Created ${resource_type} record with ID: ${id}` ); } catch (error) { // Handle phone validation errors with helpful messages if (error instanceof PhoneValidationError) { return errorResult(error.message, { code: error.code, input: error.input, country: error.country, hint: 'Provide phone numbers in E.164 format (e.g., +1 555 123 4567) or configure defaultCountry.', }); } const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to create record', details); } } /** * Update record handler * * @param client - HTTP client for API requests * @param params - Record update parameters * @param config - Optional configuration (e.g., default country for phone numbers) */ export async function handleUpdateRecord( client: HttpClient, params: { resource_type: ResourceType; record_id: string; record_data: Record<string, unknown>; return_details?: boolean; }, config?: ToolHandlerConfig ): Promise<ToolResult> { try { const { resource_type, record_id, record_data, return_details = true, } = params; const objectSlug = getObjectSlug(resource_type); // Transform record_data to Attio's expected format with values wrapper // Phone numbers are validated and normalized to E.164 format const data = transformRecordData(record_data, config); const response = await client.patch<AttioApiResponse<AttioRecord>>( `/v2/objects/${objectSlug}/records/${record_id}`, { data } ); const record = response.data.data; if (return_details) { return structuredResult( record, `Updated ${resource_type} record:\n${formatRecordDetails(record, resource_type)}` ); } return structuredResult( record, `Updated ${resource_type} record with ID: ${record_id}` ); } catch (error) { // Handle phone validation errors with helpful messages if (error instanceof PhoneValidationError) { return errorResult(error.message, { code: error.code, input: error.input, country: error.country, hint: 'Provide phone numbers in E.164 format (e.g., +1 555 123 4567) or configure defaultCountry.', }); } const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to update record', details); } } /** * Delete record handler */ export async function handleDeleteRecord( client: HttpClient, params: { resource_type: ResourceType; record_id: string; } ): Promise<ToolResult> { try { const { resource_type, record_id } = params; const objectSlug = getObjectSlug(resource_type); await client.delete(`/v2/objects/${objectSlug}/records/${record_id}`); return structuredResult( { record_id }, `Deleted ${resource_type} record with ID: ${record_id}` ); } catch (error) { const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to delete record', details); } } /** * Discover attributes handler */ export async function handleDiscoverAttributes( client: HttpClient, params: { resource_type: ResourceType; categories?: string[]; } ): Promise<ToolResult> { try { const { resource_type, categories } = params; const objectSlug = getObjectSlug(resource_type); const response = await client.get< AttioApiResponse< Array<{ api_slug: string; title: string; type: string; is_required: boolean; }> > >(`/v2/objects/${objectSlug}/attributes`); let attributes = response.data.data; // Filter by categories if provided if (categories && categories.length > 0) { // This is a simplified filter - the actual API might have different category handling attributes = attributes.filter((attr) => categories.some((cat) => attr.type?.toLowerCase().includes(cat.toLowerCase()) ) ); } if (attributes.length === 0) { return structuredResult([], `No attributes found for ${resource_type}`); } const lines = attributes.map((attr) => { const required = attr.is_required ? ' (required)' : ''; return `- ${attr.api_slug}: ${attr.type}${required} - ${attr.title}`; }); return structuredResult( attributes, `Attributes for ${resource_type}:\n${lines.join('\n')}` ); } catch (error) { const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to discover attributes', details); } } /** * Create note handler */ export async function handleCreateNote( client: HttpClient, params: { resource_type: 'companies' | 'people' | 'deals'; record_id: string; title: string; content: string; format?: 'plaintext' | 'markdown'; } ): Promise<ToolResult> { try { const { resource_type, record_id, title, content, format = 'plaintext', } = params; const response = await client.post<AttioApiResponse<AttioNote>>( '/v2/notes', { data: { parent_object: resource_type, parent_record_id: record_id, title, format, content, // API expects 'content', not 'content_plaintext' }, } ); const note = response.data.data; const noteId = note.id?.note_id || 'unknown'; return structuredResult( note, `Created note "${title}" (ID: ${noteId}) on ${resource_type} ${record_id}` ); } catch (error) { const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to create note', details); } } /** * List notes handler */ export async function handleListNotes( client: HttpClient, params: { resource_type: 'companies' | 'people' | 'deals'; record_id: string; limit?: number; offset?: number; } ): Promise<ToolResult> { try { const { resource_type, record_id, limit = 10, offset = 0 } = params; // Notes API uses GET with query params, not POST const queryParams = new URLSearchParams({ parent_object: resource_type, parent_record_id: record_id, limit: String(limit), offset: String(offset), }); const response = await client.get<AttioApiResponse<AttioNote[]>>( `/v2/notes?${queryParams.toString()}` ); const notes = response.data.data; if (!notes || notes.length === 0) { return structuredResult( [], `No notes found for ${resource_type} ${record_id}` ); } const lines = notes.map((note) => { const noteId = note.id?.note_id || 'unknown'; const title = note.title || 'Untitled'; const preview = (note.content_plaintext || '').slice(0, 100); return `- ${title} (ID: ${noteId})\n ${preview}${preview.length >= 100 ? '...' : ''}`; }); return structuredResult( notes, `Notes for ${resource_type} ${record_id}:\n${lines.join('\n')}` ); } catch (error) { const { message, details } = extractErrorInfo(error); return errorResult(message || 'Failed to list notes', details); } } /** * Handler map for tool dispatch */ export type ToolHandler = ( client: HttpClient, params: Record<string, unknown> ) => Promise<ToolResult>; /** * Get handler for a tool by name */ export function getToolHandler( toolName: string ): | (( client: HttpClient, params: Record<string, unknown> ) => Promise<ToolResult>) | undefined { const handlers: Record< string, (client: HttpClient, params: Record<string, unknown>) => Promise<ToolResult> > = { 'aaa-health-check': async (_client, params) => handleHealthCheck(params as { echo?: string }), records_search: async (client, params) => handleSearchRecords( client, params as Parameters<typeof handleSearchRecords>[1] ), records_get_details: async (client, params) => handleGetRecordDetails( client, params as Parameters<typeof handleGetRecordDetails>[1] ), 'create-record': async (client, params) => handleCreateRecord( client, params as Parameters<typeof handleCreateRecord>[1] ), 'update-record': async (client, params) => handleUpdateRecord( client, params as Parameters<typeof handleUpdateRecord>[1] ), 'delete-record': async (client, params) => handleDeleteRecord( client, params as Parameters<typeof handleDeleteRecord>[1] ), records_discover_attributes: async (client, params) => handleDiscoverAttributes( client, params as Parameters<typeof handleDiscoverAttributes>[1] ), 'create-note': async (client, params) => handleCreateNote( client, params as Parameters<typeof handleCreateNote>[1] ), 'list-notes': async (client, params) => handleListNotes(client, params as Parameters<typeof handleListNotes>[1]), }; return handlers[toolName]; }

Implementation Reference

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