Skip to content

Commit

Permalink
feat: Add search_records tool
Browse files Browse the repository at this point in the history
  • Loading branch information
domdomegg committed Dec 22, 2024
1 parent dbd6b3c commit 790be78
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 12 deletions.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 68 additions & 9 deletions src/airtableService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AirtableRecord[]> {
const maxRecords = options?.maxRecords;
async listRecords(baseId: string, tableId: string, options: ListRecordsOptions = {}): Promise<AirtableRecord[]> {
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
Expand All @@ -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;
Expand Down Expand Up @@ -199,4 +193,69 @@ export class AirtableService implements IAirtableService {
},
);
}

private async validateAndGetSearchFields(
baseId: string,
tableId: string,
requestedFieldIds?: string[],
): Promise<string[]> {
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<AirtableRecord[]> {
// 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 });
}
}
2 changes: 1 addition & 1 deletion src/mcpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
20 changes: 19 additions & 1 deletion src/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
UpdateTableArgsSchema,
CreateFieldArgsSchema,
UpdateFieldArgsSchema,
SearchRecordsArgsSchema,
IAirtableService,
IAirtableMCPServer,
} from './types.js';
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -537,6 +546,7 @@ export type AirtableRecord = { id: string, fields: FieldSet };

export interface ListRecordsOptions {
maxRecords?: number | undefined;
filterByFormula?: string | undefined;
}

export interface IAirtableService {
Expand All @@ -551,6 +561,7 @@ export interface IAirtableService {
updateTable(baseId: string, tableId: string, updates: { name?: string | undefined; description?: string | undefined }): Promise<Table>;
createField(baseId: string, tableId: string, field: Field): Promise<Field & { id: string }>;
updateField(baseId: string, tableId: string, fieldId: string, updates: { name?: string | undefined; description?: string | undefined }): Promise<Field & { id: string }>;
searchRecords(baseId: string, tableId: string, searchTerm: string, fieldIds?: string[], maxRecords?: number): Promise<AirtableRecord[]>;
}

export interface IAirtableMCPServer {
Expand Down

0 comments on commit 790be78

Please sign in to comment.