Skip to main content
Glama
NoteSearchStrategy.tsβ€’10.6 kB
/** * Note search strategy implementation * Issue #888: Fix notes search - notes cannot be found by title/content * * **IMPORTANT API LIMITATION**: * The Attio Notes API /v2/notes endpoint returns an empty array when called without filters. * Testing with curl confirmed: GET /v2/notes returns {"data": []} * The API requires parent_object and/or parent_record_id filters to return any notes. * * **What Works**: * - list-notes tool with parent record filtering (e.g., list all notes on a company) * - Content search within notes for a specific parent record * * **What Doesn't Work**: * - Workspace-wide note search without parent filters * - Global search like "find all notes containing 'demo'" across all records * * Recommendation: Request Attio to add workspace-wide notes endpoint or search capability. */ import { AttioRecord, AttioNote } from '../../types/attio.js'; import { SearchType, MatchType, SortType, UniversalResourceType, } from '../../handlers/tool-configs/universal/types.js'; import { BaseSearchStrategy } from './BaseSearchStrategy.js'; import { SearchStrategyParams, StrategyDependencies } from './interfaces.js'; import { performance } from 'perf_hooks'; import { SearchUtilities } from '../search-utilities/SearchUtilities.js'; import { createScopedLogger, OperationType } from '../../utils/logger.js'; // Import performance tracking and caching services import { enhancedPerformanceTracker } from '../../middleware/performance-enhanced.js'; import { CachingService } from '../CachingService.js'; import { UniversalUtilityService } from '../UniversalUtilityService.js'; // Performance warning threshold for large note datasets const NOTES_PERFORMANCE_WARNING_THRESHOLD = 2000; /** * Search strategy for notes with performance optimization, caching, and content search support * * IMPLEMENTATION NOTE: * The Attio Notes API (/notes endpoint) does not support native text search. * It only supports filtering by parent_object and parent_record_id. * Therefore, we fetch all notes and apply client-side filtering for search queries. */ export class NoteSearchStrategy extends BaseSearchStrategy { constructor(dependencies: StrategyDependencies) { super(dependencies); } getResourceType(): string { return UniversalResourceType.NOTES; } supportsAdvancedFiltering(): boolean { return false; // Notes only support basic search } supportsQuerySearch(): boolean { return true; // Notes support content search via applyContentSearch method } async search(params: SearchStrategyParams): Promise<AttioRecord[]> { const { query, limit, offset, search_type = SearchType.BASIC, fields, match_type = MatchType.PARTIAL, sort = SortType.NAME, filters, } = params; // Extract performance tracking IDs from parameters (passed via dependencies) const perfId = 'notes_search'; // Default fallback const apiStart = performance.now(); return this.searchNotes( perfId, apiStart, query, limit, offset, search_type, fields, match_type, sort, filters ); } /** * Search notes with performance optimization, caching, and content search support * * PERFORMANCE-OPTIMIZED NOTES PAGINATION * * The Attio Notes API does not support native text search or advanced filtering. * This implementation uses smart caching and performance monitoring to * minimize the performance impact of loading all notes. * * Optimizations: * - Smart caching with 30-second TTL to avoid repeated full loads * - Performance warnings for large datasets (>500 notes) * - Early termination for large offsets * - Memory usage monitoring and cleanup */ private async searchNotes( perfId: string, apiStart: number, query?: string, limit?: number, offset?: number, search_type: SearchType = SearchType.BASIC, fields?: string[], match_type: MatchType = MatchType.PARTIAL, sort: SortType = SortType.NAME, filters?: Record<string, unknown> ): Promise<AttioRecord[]> { const log = createScopedLogger( 'NoteSearchStrategy', 'notes_search', OperationType.DATA_PROCESSING ); // Use CachingService for notes data management const loadNotesData = async (): Promise<AttioRecord[]> => { try { if (!this.dependencies.noteFunction) { throw new Error('Notes list function not available'); } // Build query params for filtering if provided const queryParams: Record<string, unknown> = {}; if (filters) { if (filters.parent_object || filters.linked_record_type) { queryParams.parent_object = filters.parent_object || filters.linked_record_type; } if (filters.parent_record_id || filters.linked_record_id) { queryParams.parent_record_id = filters.parent_record_id || filters.linked_record_id; } } const notesResponse = await this.dependencies.noteFunction(queryParams); const notesList = notesResponse.data || []; // Convert notes to records and ensure it's always an array if (!Array.isArray(notesList)) { log.warn('NOTES API WARNING: listNotes() returned non-array value', { returnedType: typeof notesList, }); return []; } else { // Convert AttioNote[] to AttioRecord[] // Cast to AttioNote[] since we know the API returns notes return (notesList as AttioNote[]).map((note) => this.convertNoteToRecord(note) ); } } catch (error: unknown) { log.error('Failed to load notes from API', error); return []; // Fallback to empty array } }; // Generate cache key based on parent filters to prevent collisions const cacheKey = CachingService.getNotesListCacheKey(filters); const { data: notes, fromCache } = await CachingService.getOrLoadNotes( loadNotesData, cacheKey ); // Performance warning for large datasets if (!fromCache && notes.length > NOTES_PERFORMANCE_WARNING_THRESHOLD) { log.warn('PERFORMANCE WARNING: Large notes load', { noteCount: notes.length, threshold: NOTES_PERFORMANCE_WARNING_THRESHOLD, recommendation: 'Consider requesting Attio API pagination support for notes endpoint.', }); } // Log performance metrics if (!fromCache) { enhancedPerformanceTracker.markTiming( perfId, 'attioApi', performance.now() - apiStart ); } else { enhancedPerformanceTracker.markTiming(perfId, 'other', 1); } // Handle empty dataset cleanly if (notes.length === 0) { return []; // No warning for empty datasets } // Apply content search filtering if requested let filteredNotes = notes; if (search_type === SearchType.CONTENT && query && query.trim()) { filteredNotes = this.applyContentSearch( notes, query.trim(), fields, match_type, sort ); } else if (query && query.trim()) { // For BASIC search type, also apply content filtering filteredNotes = this.applyContentSearch( notes, query.trim(), fields, match_type, sort ); } // Smart pagination with early termination for unreasonable offsets const start = offset || 0; const requestedLimit = limit || 10; // Performance optimization: Don't process if offset exceeds dataset if (start >= filteredNotes.length) { log.info('Notes pagination offset exceeds dataset size', { offset: start, filteredSize: filteredNotes.length, action: 'returning empty results', }); return []; } else { const end = Math.min(start + requestedLimit, filteredNotes.length); const paginatedNotes = filteredNotes.slice(start, end); // Log pagination performance metrics enhancedPerformanceTracker.markTiming( perfId, 'serialization', fromCache ? 1 : performance.now() - apiStart ); return paginatedNotes; } } /** * Apply content search filtering to notes */ private applyContentSearch( notes: AttioRecord[], query: string, fields?: string[], matchType: MatchType = MatchType.PARTIAL, sort: SortType = SortType.NAME ): AttioRecord[] { const searchFields = fields || [ 'title', 'content_markdown', 'content_plaintext', ]; const queryLower = query.toLowerCase(); let filteredNotes = notes.filter((note: AttioRecord) => { return searchFields.some((field) => { const fieldValue = SearchUtilities.getNoteFieldValue(note, field); if (matchType === MatchType.EXACT) { return fieldValue.toLowerCase() === queryLower; } else { return fieldValue.toLowerCase().includes(queryLower); } }); }); // Apply relevance ranking if requested if (sort === SortType.RELEVANCE) { filteredNotes = SearchUtilities.rankByRelevance( filteredNotes, query, searchFields ); } return filteredNotes; } /** * Convert AttioNote to AttioRecord format */ private convertNoteToRecord(note: AttioNote): AttioRecord { // Handle id field which can be string or AttioNoteIdentifier const noteId = typeof note.id === 'string' ? note.id : note.id?.note_id || ''; // Extract content fields - API returns content_markdown, content_plaintext, or content // Match the normalization logic from src/objects/notes.ts:normalizeNoteResponse const contentMarkdown = (note.content_markdown ?? note.content ?? '') as string; const contentPlaintext = (note.content_plaintext ?? note.content ?? '') as string; // Return properly typed record satisfying AttioRecord interface const record: AttioRecord = { id: { record_id: noteId, note_id: noteId, } as AttioRecord['id'], values: { title: note.title || '', content_markdown: contentMarkdown, content_plaintext: contentPlaintext, parent_object: note.parent_object || '', parent_record_id: note.parent_record_id || '', created_at: note.created_at || '', created_by_actor: note.created_by_actor, }, }; return record; } }

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