From 790be7873858f2b88c207b50a94b4a963a17cc23 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Sun, 22 Dec 2024 22:14:47 +0000 Subject: [PATCH] feat: Add search_records tool --- README.md | 12 ++++++- src/airtableService.ts | 77 +++++++++++++++++++++++++++++++++++++----- src/mcpServer.test.ts | 2 +- src/mcpServer.ts | 20 ++++++++++- src/types.ts | 11 ++++++ 5 files changed, 110 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5249039..0206fb3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,17 @@ Replace `pat123.abc123` with your [Airtable personal access token](https://airta - Input parameters: - `baseId` (string, required): The ID of the Airtable base - `tableId` (string, required): The ID of the table to query - - `maxRecords` (number, optional): Maximum number of records to return + - `maxRecords` (number, optional): Maximum number of records to return. Defaults to 100. + - `filterByFormula` (string, optional): Airtable formula to filter records + +- **search_records** + - Search for records containing specific text + - Input parameters: + - `baseId` (string, required): The ID of the Airtable base + - `tableId` (string, required): The ID of the table to query + - `searchTerm` (string, required): Text to search for in records + - `fieldIds` (array, optional): Specific field IDs to search in. If not provided, searches all text-based fields. + - `maxRecords` (number, optional): Maximum number of records to return. Defaults to 100. - **list_bases** - Lists all accessible Airtable bases diff --git a/src/airtableService.ts b/src/airtableService.ts index 2309208..637e03a 100644 --- a/src/airtableService.ts +++ b/src/airtableService.ts @@ -69,14 +69,14 @@ export class AirtableService implements IAirtableService { return this.fetchFromAPI(`/v0/meta/bases/${baseId}/tables`, BaseSchemaResponseSchema); } - async listRecords(baseId: string, tableId: string, options?: ListRecordsOptions): Promise { - const maxRecords = options?.maxRecords; + async listRecords(baseId: string, tableId: string, options: ListRecordsOptions = {}): Promise { let allRecords: AirtableRecord[] = []; let offset: string | undefined; do { const queryParams = new URLSearchParams(); - if (maxRecords) queryParams.append('maxRecords', maxRecords.toString()); + if (options.maxRecords) queryParams.append('maxRecords', options.maxRecords.toString()); + if (options.filterByFormula) queryParams.append('filterByFormula', options.filterByFormula); if (offset) queryParams.append('offset', offset); // eslint-disable-next-line no-await-in-loop @@ -90,12 +90,6 @@ export class AirtableService implements IAirtableService { allRecords = allRecords.concat(response.records); offset = response.offset; - - // Stop if we've reached maxRecords - if (maxRecords && allRecords.length >= maxRecords) { - allRecords = allRecords.slice(0, maxRecords); - break; - } } while (offset); return allRecords; @@ -199,4 +193,69 @@ export class AirtableService implements IAirtableService { }, ); } + + private async validateAndGetSearchFields( + baseId: string, + tableId: string, + requestedFieldIds?: string[], + ): Promise { + const schema = await this.getBaseSchema(baseId); + const table = schema.tables.find((t) => t.id === tableId); + if (!table) { + throw new Error(`Table ${tableId} not found in base ${baseId}`); + } + + const searchableFieldTypes = [ + 'singleLineText', + 'multilineText', + 'richText', + 'email', + 'url', + 'phoneNumber', + ]; + + const searchableFields = table.fields + .filter((field) => searchableFieldTypes.includes(field.type)) + .map((field) => field.id); + + if (searchableFields.length === 0) { + throw new Error('No text fields available to search'); + } + + // If specific fields were requested, validate they exist and are text fields + if (requestedFieldIds && requestedFieldIds.length > 0) { + // Check if any requested fields were invalid + const invalidFields = requestedFieldIds.filter((fieldId) => !searchableFields.includes(fieldId)); + if (invalidFields.length > 0) { + throw new Error(`Invalid fields requested: ${invalidFields.join(', ')}`); + } + + return requestedFieldIds; + } + + return searchableFields; + } + + async searchRecords( + baseId: string, + tableId: string, + searchTerm: string, + fieldIds?: string[], + maxRecords?: number, + ): Promise { + // Validate and get search fields + const searchFields = await this.validateAndGetSearchFields(baseId, tableId, fieldIds); + + // Escape the search term to prevent formula injection + const escapedTerm = searchTerm.replace(/["\\]/g, '\\$&'); + + // Build OR(FIND("term", field1), FIND("term", field2), ...) + const filterByFormula = `OR(${ + searchFields + .map((fieldId) => `FIND("${escapedTerm}", {${fieldId}})`) + .join(',') + })`; + + return this.listRecords(baseId, tableId, { maxRecords, filterByFormula }); + } } diff --git a/src/mcpServer.test.ts b/src/mcpServer.test.ts index 21116b1..d7684f0 100644 --- a/src/mcpServer.test.ts +++ b/src/mcpServer.test.ts @@ -145,7 +145,7 @@ describe('AirtableMCPServer', () => { params: {}, }); - expect(response.result.tools).toHaveLength(11); + expect((response.result.tools as Tool[]).length).toBeGreaterThanOrEqual(12); expect((response.result.tools as Tool[])[0]).toMatchObject({ name: 'list_records', description: expect.any(String), diff --git a/src/mcpServer.ts b/src/mcpServer.ts index 58634e8..f6c11c8 100644 --- a/src/mcpServer.ts +++ b/src/mcpServer.ts @@ -23,6 +23,7 @@ import { UpdateTableArgsSchema, CreateFieldArgsSchema, UpdateFieldArgsSchema, + SearchRecordsArgsSchema, IAirtableService, IAirtableMCPServer, } from './types.js'; @@ -137,6 +138,11 @@ export class AirtableMCPServer implements IAirtableMCPServer { description: 'List records from a table', inputSchema: getInputSchema(ListRecordsArgsSchema), }, + { + name: 'search_records', + description: 'Search for records containing specific text', + inputSchema: getInputSchema(SearchRecordsArgsSchema), + }, { name: 'list_bases', description: 'List all accessible Airtable bases', @@ -203,7 +209,19 @@ export class AirtableMCPServer implements IAirtableMCPServer { const records = await this.airtableService.listRecords( args.baseId, args.tableId, - { maxRecords: args.maxRecords }, + { maxRecords: args.maxRecords, filterByFormula: args.filterByFormula }, + ); + return formatToolResponse(records); + } + + case 'search_records': { + const args = SearchRecordsArgsSchema.parse(request.params.arguments); + const records = await this.airtableService.searchRecords( + args.baseId, + args.tableId, + args.searchTerm, + args.fieldIds, + args.maxRecords, ); return formatToolResponse(records); } diff --git a/src/types.ts b/src/types.ts index 6ed857c..f443d1b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -460,6 +460,15 @@ export const ListRecordsArgsSchema = z.object({ baseId: z.string(), tableId: z.string(), maxRecords: z.number().optional().describe('Maximum number of records to return. Defaults to 100.'), + filterByFormula: z.string().optional().describe('Airtable formula to filter records'), +}); + +export const SearchRecordsArgsSchema = z.object({ + baseId: z.string(), + tableId: z.string(), + searchTerm: z.string().describe('Text to search for in records'), + fieldIds: z.array(z.string()).optional().describe('Specific field ids to search in. If not provided, searches all text-based fields.'), + maxRecords: z.number().optional().describe('Maximum number of records to return. Defaults to 100.'), }); export const ListTablesArgsSchema = z.object({ @@ -537,6 +546,7 @@ export type AirtableRecord = { id: string, fields: FieldSet }; export interface ListRecordsOptions { maxRecords?: number | undefined; + filterByFormula?: string | undefined; } export interface IAirtableService { @@ -551,6 +561,7 @@ export interface IAirtableService { updateTable(baseId: string, tableId: string, updates: { name?: string | undefined; description?: string | undefined }): Promise; createField(baseId: string, tableId: string, field: Field): Promise; updateField(baseId: string, tableId: string, fieldId: string, updates: { name?: string | undefined; description?: string | undefined }): Promise; + searchRecords(baseId: string, tableId: string, searchTerm: string, fieldIds?: string[], maxRecords?: number): Promise; } export interface IAirtableMCPServer {