Skip to main content
Glama

airtable-mcp-server

import { describe, test, expect, vi, beforeEach, type MockedFunction, } from 'vitest'; import {AirtableService} from './airtableService.js'; describe('AirtableService', () => { const mockApiKey = 'test-api-key'; const mockBaseUrl = 'https://api.airtable.com'; let service: AirtableService; let mockFetch: MockedFunction<(...args: Parameters<typeof fetch>) => Promise<Partial<Response>>>; beforeEach(() => { // Create a mock fetch function that we'll inject mockFetch = vi.fn().mockResolvedValue({ ok: true, text: async () => Promise.resolve(JSON.stringify({success: true})), }); // Initialize service with our mock fetch service = new AirtableService(mockApiKey, mockBaseUrl, mockFetch as typeof fetch); }); describe('constructor', () => { test('initializes with default base URL', () => { const defaultService = new AirtableService(mockApiKey, undefined, mockFetch as typeof fetch); expect(defaultService).toBeInstanceOf(AirtableService); }); test('initializes with custom base URL', () => { const customService = new AirtableService(mockApiKey, 'https://custom.url', mockFetch as typeof fetch); expect(customService).toBeInstanceOf(AirtableService); }); }); describe('API calls', () => { describe('listBases', () => { const mockResponse = { bases: [ {id: 'base1', name: 'Base 1', permissionLevel: 'create'}, ], }; test('fetches bases list successfully', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); const result = await service.listBases(); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/meta/bases`, expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${mockApiKey}`, Accept: 'application/json', }), }), ); expect(result).toEqual(mockResponse); }); }); describe('getBaseSchema', () => { const mockBaseId = 'base123'; const mockResponse = { tables: [ { id: 'tbl1', name: 'Table 1', primaryFieldId: 'fld1', fields: [ { id: 'fld1', name: 'Name', type: 'singleLineText', }, ], views: [ { id: 'viw1', name: 'Grid view', type: 'grid', }, ], }, ], }; test('fetches base schema successfully', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); const result = await service.getBaseSchema(mockBaseId); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/meta/bases/${mockBaseId}/tables`, expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${mockApiKey}`, }), }), ); expect(result).toEqual(mockResponse); }); }); describe('listRecords', () => { const mockBaseId = 'base123'; const mockTableId = 'table123'; const mockResponse = { records: [ {id: 'rec1', fields: {name: 'Test'}}, ], }; test('lists records successfully', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); const result = await service.listRecords(mockBaseId, mockTableId); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}?`, expect.any(Object), ); expect(result).toEqual(mockResponse.records); }); test('handles maxRecords option', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); await service.listRecords(mockBaseId, mockTableId, {maxRecords: 100}); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}?maxRecords=100`, expect.any(Object), ); }); test('handles view option', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); await service.listRecords(mockBaseId, mockTableId, {view: 'viw123'}); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}?view=viw123`, expect.any(Object), ); }); test('handles sort option with single field', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); await service.listRecords(mockBaseId, mockTableId, { sort: [{field: 'Name'}], }); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}?sort%5B0%5D%5Bfield%5D=Name`, expect.any(Object), ); }); test('handles sort option with multiple fields and directions', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); await service.listRecords(mockBaseId, mockTableId, { sort: [ {field: 'Name', direction: 'asc'}, {field: 'CreatedTime', direction: 'desc'}, ], }); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining(`${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}?`), expect.any(Object), ); // Check the URL contains all the sort parameters const url = mockFetch.mock.calls[0]?.[0]; expect(typeof url).toBe('string'); expect(url).toContain('sort%5B0%5D%5Bfield%5D=Name'); expect(url).toContain('sort%5B0%5D%5Bdirection%5D=asc'); expect(url).toContain('sort%5B1%5D%5Bfield%5D=CreatedTime'); expect(url).toContain('sort%5B1%5D%5Bdirection%5D=desc'); }); }); describe('error handling', () => { test('handles API errors', async () => { const errorMessage = 'API Error'; mockFetch.mockResolvedValueOnce({ ok: false, statusText: errorMessage, text: async () => Promise.resolve('Error response'), }); await expect(service.listBases()).rejects.toThrow('Airtable API Error'); }); test('enhances AUTHENTICATION_REQUIRED errors', async () => { const errorResponse = JSON.stringify({ error: { type: 'AUTHENTICATION_REQUIRED', message: 'Authentication required', }, }); mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Unauthorized', text: async () => Promise.resolve(errorResponse), }); await expect(service.listBases()).rejects.toThrow(/Verify that the token hasn't expired or been revoked./); }); test('enhances INVALID_PERMISSIONS_OR_MODEL_NOT_FOUND errors', async () => { const errorResponse = JSON.stringify({ error: { type: 'INVALID_PERMISSIONS_OR_MODEL_NOT_FOUND', message: 'Invalid permissions or model not found', }, }); mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Forbidden', text: async () => Promise.resolve(errorResponse), }); await expect(service.listBases()).rejects.toThrow(/schema\.bases:read/); }); test('handles JSON parse errors', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve('invalid json'), }); await expect(service.listBases()).rejects.toThrow('Failed to parse API response'); }); test('handles schema validation errors', async () => { mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve('{"invalidData": true}'), }); await expect(service.listBases()).rejects.toThrow(); }); }); describe('searchRecords', () => { const mockBaseId = 'base123'; const mockTableId = 'table123'; const mockResponse = { records: [ {id: 'rec1', fields: {name: 'Test Result'}}, ], }; test('searches records successfully', async () => { // Mock the getBaseSchema call to return fields for validation mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify({ tables: [{ id: mockTableId, name: 'Test Table', primaryFieldId: 'fld1', fields: [{id: 'fld1', name: 'Name', type: 'singleLineText'}], views: [{id: 'viw1', name: 'Grid view', type: 'grid'}], }], })), }); // Mock the listRecords call that searchRecords uses internally mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); const result = await service.searchRecords(mockBaseId, mockTableId, 'test'); // Verify the second call (listRecords) has the correct filter formula expect(mockFetch).toHaveBeenCalledTimes(2); const secondCallUrl = mockFetch.mock.calls[1]?.[0] as string; expect(secondCallUrl).toContain('filterByFormula='); expect(result).toEqual(mockResponse.records); }); test('handles view parameter', async () => { // Mock the getBaseSchema call to return fields for validation mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify({ tables: [{ id: mockTableId, name: 'Test Table', primaryFieldId: 'fld1', fields: [{id: 'fld1', name: 'Name', type: 'singleLineText'}], views: [{id: 'viw1', name: 'Grid view', type: 'grid'}], }], })), }); // Mock the listRecords call that searchRecords uses internally mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); await service.searchRecords(mockBaseId, mockTableId, 'test', undefined, 100, 'viw123'); // Verify the second call (listRecords) has the view parameter expect(mockFetch).toHaveBeenCalledTimes(2); const secondCallUrl = mockFetch.mock.calls[1]?.[0] as string; expect(secondCallUrl).toContain('view=viw123'); }); }); describe('record operations', () => { const mockBaseId = 'base123'; const mockTableId = 'table123'; const mockRecordId = 'rec123'; test('creates record successfully', async () => { const mockRecord = {id: mockRecordId, fields: {name: 'Test'}}; mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockRecord)), }); const result = await service.createRecord(mockBaseId, mockTableId, {name: 'Test'}); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}`, expect.objectContaining({ method: 'POST', body: expect.any(String), }), ); expect(result).toEqual(mockRecord); }); test('updates records successfully', async () => { const mockResponse = { records: [{id: mockRecordId, fields: {name: 'Updated'}}], }; mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); const records = [{id: mockRecordId, fields: {name: 'Updated'}}]; const result = await service.updateRecords(mockBaseId, mockTableId, records); expect(mockFetch).toHaveBeenCalledWith( `${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}`, expect.objectContaining({ method: 'PATCH', body: expect.any(String), }), ); expect(result).toEqual(mockResponse.records); }); test('deletes records successfully', async () => { const mockResponse = { records: [{id: mockRecordId, deleted: true}], }; mockFetch.mockResolvedValueOnce({ ok: true, text: async () => Promise.resolve(JSON.stringify(mockResponse)), }); const result = await service.deleteRecords(mockBaseId, mockTableId, [mockRecordId]); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining(`${mockBaseUrl}/v0/${mockBaseId}/${mockTableId}?records[]=${mockRecordId}`), expect.objectContaining({ method: 'DELETE', }), ); expect(result).toEqual([{id: mockRecordId}]); }); }); }); });

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/domdomegg/airtable-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server