@rashidazarang/airtable-mcp 1.2.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,7 @@ const baseId = baseIndex !== -1 ? args[baseIndex + 1] : process.env.AIRTABLE_BAS
23
23
  if (!token || !baseId) {
24
24
  console.error('Error: Missing Airtable credentials');
25
25
  console.error('\nUsage options:');
26
- console.error(' 1. Command line: node airtable_simple.js --token YOUR_TOKEN --base YOUR_BASE_ID');
26
+ console.error(' 1. Command line: node airtable_enhanced.js --token YOUR_TOKEN --base YOUR_BASE_ID');
27
27
  console.error(' 2. Environment variables: AIRTABLE_TOKEN and AIRTABLE_BASE_ID');
28
28
  console.error(' 3. .env file with AIRTABLE_TOKEN and AIRTABLE_BASE_ID');
29
29
  process.exit(1);
@@ -55,7 +55,76 @@ function log(level, message, ...args) {
55
55
  }
56
56
  }
57
57
 
58
- log(LOG_LEVELS.INFO, `Starting Airtable MCP server with token ${token.slice(0, 5)}...${token.slice(-5)} and base ${baseId}`);
58
+ log(LOG_LEVELS.INFO, `Starting Enhanced Airtable MCP server v1.4.0`);
59
+ log(LOG_LEVELS.INFO, `Token: ${token.slice(0, 5)}...${token.slice(-5)}`);
60
+ log(LOG_LEVELS.INFO, `Base ID: ${baseId}`);
61
+
62
+ // Enhanced Airtable API function with full HTTP method support
63
+ function callAirtableAPI(endpoint, method = 'GET', body = null, queryParams = {}) {
64
+ return new Promise((resolve, reject) => {
65
+ const isBaseEndpoint = !endpoint.startsWith('meta/') && !endpoint.startsWith('bases/');
66
+ const baseUrl = isBaseEndpoint ? `${baseId}/${endpoint}` : endpoint;
67
+
68
+ // Build query string
69
+ const queryString = Object.keys(queryParams).length > 0
70
+ ? '?' + new URLSearchParams(queryParams).toString()
71
+ : '';
72
+
73
+ const url = `https://api.airtable.com/v0/${baseUrl}${queryString}`;
74
+ const urlObj = new URL(url);
75
+
76
+ log(LOG_LEVELS.DEBUG, `API Request: ${method} ${url}`);
77
+ if (body) {
78
+ log(LOG_LEVELS.DEBUG, `Request body:`, JSON.stringify(body, null, 2));
79
+ }
80
+
81
+ const options = {
82
+ hostname: urlObj.hostname,
83
+ path: urlObj.pathname + urlObj.search,
84
+ method: method,
85
+ headers: {
86
+ 'Authorization': `Bearer ${token}`,
87
+ 'Content-Type': 'application/json'
88
+ }
89
+ };
90
+
91
+ const req = https.request(options, (response) => {
92
+ let data = '';
93
+
94
+ response.on('data', (chunk) => {
95
+ data += chunk;
96
+ });
97
+
98
+ response.on('end', () => {
99
+ log(LOG_LEVELS.DEBUG, `Response status: ${response.statusCode}`);
100
+ log(LOG_LEVELS.DEBUG, `Response data:`, data);
101
+
102
+ try {
103
+ const parsed = data ? JSON.parse(data) : {};
104
+
105
+ if (response.statusCode >= 200 && response.statusCode < 300) {
106
+ resolve(parsed);
107
+ } else {
108
+ const error = parsed.error || {};
109
+ reject(new Error(`Airtable API error (${response.statusCode}): ${error.message || error.type || 'Unknown error'}`));
110
+ }
111
+ } catch (e) {
112
+ reject(new Error(`Failed to parse Airtable response: ${e.message}`));
113
+ }
114
+ });
115
+ });
116
+
117
+ req.on('error', (error) => {
118
+ reject(new Error(`Airtable API request failed: ${error.message}`));
119
+ });
120
+
121
+ if (body) {
122
+ req.write(JSON.stringify(body));
123
+ }
124
+
125
+ req.end();
126
+ });
127
+ }
59
128
 
60
129
  // Create HTTP server
61
130
  const server = http.createServer(async (req, res) => {
@@ -86,8 +155,179 @@ const server = http.createServer(async (req, res) => {
86
155
  req.on('end', async () => {
87
156
  try {
88
157
  const request = JSON.parse(body);
158
+ log(LOG_LEVELS.DEBUG, 'Received request:', JSON.stringify(request, null, 2));
89
159
 
90
160
  // Handle JSON-RPC methods
161
+ if (request.method === 'tools/list') {
162
+ const response = {
163
+ jsonrpc: '2.0',
164
+ id: request.id,
165
+ result: {
166
+ tools: [
167
+ {
168
+ name: 'list_tables',
169
+ description: 'List all tables in the Airtable base',
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: {}
173
+ }
174
+ },
175
+ {
176
+ name: 'list_records',
177
+ description: 'List records from a specific table',
178
+ inputSchema: {
179
+ type: 'object',
180
+ properties: {
181
+ table: { type: 'string', description: 'Table name or ID' },
182
+ maxRecords: { type: 'number', description: 'Maximum number of records to return' },
183
+ view: { type: 'string', description: 'View name or ID' }
184
+ },
185
+ required: ['table']
186
+ }
187
+ },
188
+ {
189
+ name: 'get_record',
190
+ description: 'Get a single record by ID',
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ table: { type: 'string', description: 'Table name or ID' },
195
+ recordId: { type: 'string', description: 'Record ID' }
196
+ },
197
+ required: ['table', 'recordId']
198
+ }
199
+ },
200
+ {
201
+ name: 'create_record',
202
+ description: 'Create a new record in a table',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ table: { type: 'string', description: 'Table name or ID' },
207
+ fields: { type: 'object', description: 'Field values for the new record' }
208
+ },
209
+ required: ['table', 'fields']
210
+ }
211
+ },
212
+ {
213
+ name: 'update_record',
214
+ description: 'Update an existing record',
215
+ inputSchema: {
216
+ type: 'object',
217
+ properties: {
218
+ table: { type: 'string', description: 'Table name or ID' },
219
+ recordId: { type: 'string', description: 'Record ID to update' },
220
+ fields: { type: 'object', description: 'Fields to update' }
221
+ },
222
+ required: ['table', 'recordId', 'fields']
223
+ }
224
+ },
225
+ {
226
+ name: 'delete_record',
227
+ description: 'Delete a record from a table',
228
+ inputSchema: {
229
+ type: 'object',
230
+ properties: {
231
+ table: { type: 'string', description: 'Table name or ID' },
232
+ recordId: { type: 'string', description: 'Record ID to delete' }
233
+ },
234
+ required: ['table', 'recordId']
235
+ }
236
+ },
237
+ {
238
+ name: 'search_records',
239
+ description: 'Search records with filtering and sorting',
240
+ inputSchema: {
241
+ type: 'object',
242
+ properties: {
243
+ table: { type: 'string', description: 'Table name or ID' },
244
+ filterByFormula: { type: 'string', description: 'Airtable formula to filter records' },
245
+ sort: { type: 'array', description: 'Sort configuration' },
246
+ maxRecords: { type: 'number', description: 'Maximum records to return' },
247
+ fields: { type: 'array', description: 'Fields to return' }
248
+ },
249
+ required: ['table']
250
+ }
251
+ },
252
+ {
253
+ name: 'list_webhooks',
254
+ description: 'List all webhooks for the base',
255
+ inputSchema: {
256
+ type: 'object',
257
+ properties: {}
258
+ }
259
+ },
260
+ {
261
+ name: 'create_webhook',
262
+ description: 'Create a new webhook for a table',
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: {
266
+ notificationUrl: { type: 'string', description: 'URL to receive webhook notifications' },
267
+ specification: {
268
+ type: 'object',
269
+ description: 'Webhook specification',
270
+ properties: {
271
+ options: {
272
+ type: 'object',
273
+ properties: {
274
+ filters: {
275
+ type: 'object',
276
+ properties: {
277
+ dataTypes: { type: 'array', items: { type: 'string' } },
278
+ recordChangeScope: { type: 'string' }
279
+ }
280
+ }
281
+ }
282
+ }
283
+ }
284
+ }
285
+ },
286
+ required: ['notificationUrl']
287
+ }
288
+ },
289
+ {
290
+ name: 'delete_webhook',
291
+ description: 'Delete a webhook',
292
+ inputSchema: {
293
+ type: 'object',
294
+ properties: {
295
+ webhookId: { type: 'string', description: 'Webhook ID to delete' }
296
+ },
297
+ required: ['webhookId']
298
+ }
299
+ },
300
+ {
301
+ name: 'get_webhook_payloads',
302
+ description: 'Get webhook payload history',
303
+ inputSchema: {
304
+ type: 'object',
305
+ properties: {
306
+ webhookId: { type: 'string', description: 'Webhook ID' },
307
+ cursor: { type: 'number', description: 'Cursor for pagination' }
308
+ },
309
+ required: ['webhookId']
310
+ }
311
+ },
312
+ {
313
+ name: 'refresh_webhook',
314
+ description: 'Refresh a webhook to extend its expiration',
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: {
318
+ webhookId: { type: 'string', description: 'Webhook ID to refresh' }
319
+ },
320
+ required: ['webhookId']
321
+ }
322
+ }
323
+ ]
324
+ }
325
+ };
326
+ res.writeHead(200, { 'Content-Type': 'application/json' });
327
+ res.end(JSON.stringify(response));
328
+ return;
329
+ }
330
+
91
331
  if (request.method === 'resources/list') {
92
332
  const response = {
93
333
  jsonrpc: '2.0',
@@ -129,15 +369,207 @@ const server = http.createServer(async (req, res) => {
129
369
  // Handle tool calls
130
370
  if (request.method === 'tools/call') {
131
371
  const toolName = request.params.name;
372
+ const toolParams = request.params.arguments || {};
373
+
374
+ let result;
375
+ let responseText;
132
376
 
133
- if (toolName === 'list_tables') {
134
- // Call Airtable API to list tables
135
- const result = await callAirtableAPI(`meta/bases/${baseId}/tables`);
136
- const tables = result.tables || [];
377
+ try {
378
+ // LIST TABLES
379
+ if (toolName === 'list_tables') {
380
+ result = await callAirtableAPI(`meta/bases/${baseId}/tables`);
381
+ const tables = result.tables || [];
382
+
383
+ responseText = tables.length > 0
384
+ ? `Found ${tables.length} table(s):\n` + tables.map((table, i) =>
385
+ `${i+1}. ${table.name} (ID: ${table.id}, Fields: ${table.fields?.length || 0})`
386
+ ).join('\n')
387
+ : 'No tables found in this base.';
388
+ }
389
+
390
+ // LIST RECORDS
391
+ else if (toolName === 'list_records') {
392
+ const { table, maxRecords, view } = toolParams;
393
+
394
+ const queryParams = {};
395
+ if (maxRecords) queryParams.maxRecords = maxRecords;
396
+ if (view) queryParams.view = view;
397
+
398
+ result = await callAirtableAPI(`${table}`, 'GET', null, queryParams);
399
+ const records = result.records || [];
400
+
401
+ responseText = records.length > 0
402
+ ? `Found ${records.length} record(s) in table "${table}":\n` +
403
+ records.map((record, i) =>
404
+ `${i+1}. ID: ${record.id}\n Fields: ${JSON.stringify(record.fields, null, 2)}`
405
+ ).join('\n\n')
406
+ : `No records found in table "${table}".`;
407
+ }
408
+
409
+ // GET SINGLE RECORD
410
+ else if (toolName === 'get_record') {
411
+ const { table, recordId } = toolParams;
412
+
413
+ result = await callAirtableAPI(`${table}/${recordId}`);
414
+
415
+ responseText = `Record ${recordId} from table "${table}":\n` +
416
+ JSON.stringify(result.fields, null, 2) +
417
+ `\n\nCreated: ${result.createdTime}`;
418
+ }
419
+
420
+ // CREATE RECORD
421
+ else if (toolName === 'create_record') {
422
+ const { table, fields } = toolParams;
423
+
424
+ const body = {
425
+ fields: fields
426
+ };
427
+
428
+ result = await callAirtableAPI(table, 'POST', body);
429
+
430
+ responseText = `Successfully created record in table "${table}":\n` +
431
+ `Record ID: ${result.id}\n` +
432
+ `Fields: ${JSON.stringify(result.fields, null, 2)}\n` +
433
+ `Created at: ${result.createdTime}`;
434
+ }
435
+
436
+ // UPDATE RECORD
437
+ else if (toolName === 'update_record') {
438
+ const { table, recordId, fields } = toolParams;
439
+
440
+ const body = {
441
+ fields: fields
442
+ };
443
+
444
+ result = await callAirtableAPI(`${table}/${recordId}`, 'PATCH', body);
445
+
446
+ responseText = `Successfully updated record ${recordId} in table "${table}":\n` +
447
+ `Updated fields: ${JSON.stringify(result.fields, null, 2)}`;
448
+ }
449
+
450
+ // DELETE RECORD
451
+ else if (toolName === 'delete_record') {
452
+ const { table, recordId } = toolParams;
453
+
454
+ result = await callAirtableAPI(`${table}/${recordId}`, 'DELETE');
455
+
456
+ responseText = `Successfully deleted record ${recordId} from table "${table}".\n` +
457
+ `Deleted record ID: ${result.id}\n` +
458
+ `Deleted: ${result.deleted}`;
459
+ }
460
+
461
+ // SEARCH RECORDS
462
+ else if (toolName === 'search_records') {
463
+ const { table, filterByFormula, sort, maxRecords, fields } = toolParams;
464
+
465
+ const queryParams = {};
466
+ if (filterByFormula) queryParams.filterByFormula = filterByFormula;
467
+ if (maxRecords) queryParams.maxRecords = maxRecords;
468
+ if (fields && fields.length > 0) queryParams.fields = fields;
469
+ if (sort && sort.length > 0) {
470
+ sort.forEach((s, i) => {
471
+ queryParams[`sort[${i}][field]`] = s.field;
472
+ queryParams[`sort[${i}][direction]`] = s.direction || 'asc';
473
+ });
474
+ }
475
+
476
+ result = await callAirtableAPI(table, 'GET', null, queryParams);
477
+ const records = result.records || [];
478
+
479
+ responseText = records.length > 0
480
+ ? `Found ${records.length} matching record(s) in table "${table}":\n` +
481
+ records.map((record, i) =>
482
+ `${i+1}. ID: ${record.id}\n Fields: ${JSON.stringify(record.fields, null, 2)}`
483
+ ).join('\n\n')
484
+ : `No records found matching the search criteria in table "${table}".`;
485
+ }
137
486
 
138
- const tableList = tables.map((table, i) =>
139
- `${i+1}. ${table.name} (ID: ${table.id})`
140
- ).join('\n');
487
+ // LIST WEBHOOKS
488
+ else if (toolName === 'list_webhooks') {
489
+ result = await callAirtableAPI(`bases/${baseId}/webhooks`, 'GET');
490
+ const webhooks = result.webhooks || [];
491
+
492
+ responseText = webhooks.length > 0
493
+ ? `Found ${webhooks.length} webhook(s):\n` +
494
+ webhooks.map((webhook, i) =>
495
+ `${i+1}. ID: ${webhook.id}\n` +
496
+ ` URL: ${webhook.notificationUrl}\n` +
497
+ ` Active: ${webhook.isHookEnabled}\n` +
498
+ ` Created: ${webhook.createdTime}\n` +
499
+ ` Expires: ${webhook.expirationTime}`
500
+ ).join('\n\n')
501
+ : 'No webhooks configured for this base.';
502
+ }
503
+
504
+ // CREATE WEBHOOK
505
+ else if (toolName === 'create_webhook') {
506
+ const { notificationUrl, specification } = toolParams;
507
+
508
+ const body = {
509
+ notificationUrl: notificationUrl,
510
+ specification: specification || {
511
+ options: {
512
+ filters: {
513
+ dataTypes: ['tableData']
514
+ }
515
+ }
516
+ }
517
+ };
518
+
519
+ result = await callAirtableAPI(`bases/${baseId}/webhooks`, 'POST', body);
520
+
521
+ responseText = `Successfully created webhook:\n` +
522
+ `Webhook ID: ${result.id}\n` +
523
+ `URL: ${result.notificationUrl}\n` +
524
+ `MAC Secret: ${result.macSecretBase64}\n` +
525
+ `Expiration: ${result.expirationTime}\n` +
526
+ `Cursor: ${result.cursorForNextPayload}\n\n` +
527
+ `⚠️ IMPORTANT: Save the MAC secret - it won't be shown again!`;
528
+ }
529
+
530
+ // DELETE WEBHOOK
531
+ else if (toolName === 'delete_webhook') {
532
+ const { webhookId } = toolParams;
533
+
534
+ await callAirtableAPI(`bases/${baseId}/webhooks/${webhookId}`, 'DELETE');
535
+
536
+ responseText = `Successfully deleted webhook ${webhookId}`;
537
+ }
538
+
539
+ // GET WEBHOOK PAYLOADS
540
+ else if (toolName === 'get_webhook_payloads') {
541
+ const { webhookId, cursor } = toolParams;
542
+
543
+ const queryParams = {};
544
+ if (cursor) queryParams.cursor = cursor;
545
+
546
+ result = await callAirtableAPI(`bases/${baseId}/webhooks/${webhookId}/payloads`, 'GET', null, queryParams);
547
+
548
+ const payloads = result.payloads || [];
549
+ responseText = payloads.length > 0
550
+ ? `Found ${payloads.length} webhook payload(s):\n` +
551
+ payloads.map((payload, i) =>
552
+ `${i+1}. Timestamp: ${payload.timestamp}\n` +
553
+ ` Base/Table: ${payload.baseTransactionNumber}\n` +
554
+ ` Change Types: ${JSON.stringify(payload.changePayload?.changedTablesById || {})}`
555
+ ).join('\n\n') +
556
+ (result.cursor ? `\n\nNext cursor: ${result.cursor}` : '')
557
+ : 'No payloads found for this webhook.';
558
+ }
559
+
560
+ // REFRESH WEBHOOK
561
+ else if (toolName === 'refresh_webhook') {
562
+ const { webhookId } = toolParams;
563
+
564
+ result = await callAirtableAPI(`bases/${baseId}/webhooks/${webhookId}/refresh`, 'POST');
565
+
566
+ responseText = `Successfully refreshed webhook ${webhookId}:\n` +
567
+ `New expiration: ${result.expirationTime}`;
568
+ }
569
+
570
+ else {
571
+ throw new Error(`Unknown tool: ${toolName}`);
572
+ }
141
573
 
142
574
  const response = {
143
575
  jsonrpc: '2.0',
@@ -146,33 +578,16 @@ const server = http.createServer(async (req, res) => {
146
578
  content: [
147
579
  {
148
580
  type: 'text',
149
- text: tables.length > 0
150
- ? `Tables in this base:\n${tableList}`
151
- : 'No tables found in this base.'
581
+ text: responseText
152
582
  }
153
583
  ]
154
584
  }
155
585
  };
156
-
157
586
  res.writeHead(200, { 'Content-Type': 'application/json' });
158
587
  res.end(JSON.stringify(response));
159
- return;
160
- }
161
-
162
- if (toolName === 'list_records') {
163
- const tableName = request.params.arguments.table_name;
164
- const maxRecords = request.params.arguments.max_records || 100;
165
588
 
166
- // Call Airtable API to list records
167
- const result = await callAirtableAPI(`${baseId}/${tableName}`, { maxRecords });
168
- const records = result.records || [];
169
-
170
- const recordList = records.map((record, i) => {
171
- const fields = Object.entries(record.fields || {})
172
- .map(([k, v]) => `${k}: ${v}`)
173
- .join(', ');
174
- return `${i+1}. ID: ${record.id} - ${fields}`;
175
- }).join('\n');
589
+ } catch (error) {
590
+ log(LOG_LEVELS.ERROR, `Tool ${toolName} error:`, error.message);
176
591
 
177
592
  const response = {
178
593
  jsonrpc: '2.0',
@@ -181,30 +596,15 @@ const server = http.createServer(async (req, res) => {
181
596
  content: [
182
597
  {
183
598
  type: 'text',
184
- text: records.length > 0
185
- ? `Records:\n${recordList}`
186
- : 'No records found in this table.'
599
+ text: `Error executing ${toolName}: ${error.message}`
187
600
  }
188
601
  ]
189
602
  }
190
603
  };
191
-
192
604
  res.writeHead(200, { 'Content-Type': 'application/json' });
193
605
  res.end(JSON.stringify(response));
194
- return;
195
606
  }
196
607
 
197
- // Tool not found
198
- const response = {
199
- jsonrpc: '2.0',
200
- id: request.id,
201
- error: {
202
- code: -32601,
203
- message: `Tool ${toolName} not found`
204
- }
205
- };
206
- res.writeHead(200, { 'Content-Type': 'application/json' });
207
- res.end(JSON.stringify(response));
208
608
  return;
209
609
  }
210
610
 
@@ -221,7 +621,7 @@ const server = http.createServer(async (req, res) => {
221
621
  res.end(JSON.stringify(response));
222
622
 
223
623
  } catch (error) {
224
- console.error('Error processing request:', error);
624
+ log(LOG_LEVELS.ERROR, 'Error processing request:', error);
225
625
  const response = {
226
626
  jsonrpc: '2.0',
227
627
  id: request.id || null,
@@ -236,42 +636,18 @@ const server = http.createServer(async (req, res) => {
236
636
  });
237
637
  });
238
638
 
239
- // Helper function to call Airtable API
240
- function callAirtableAPI(endpoint, params = {}) {
241
- return new Promise((resolve, reject) => {
242
- const queryParams = new URLSearchParams(params).toString();
243
- const url = `https://api.airtable.com/v0/${endpoint}${queryParams ? '?' + queryParams : ''}`;
244
-
245
- const options = {
246
- headers: {
247
- 'Authorization': `Bearer ${token}`,
248
- 'Content-Type': 'application/json'
249
- }
250
- };
251
-
252
- https.get(url, options, (response) => {
253
- let data = '';
254
-
255
- response.on('data', (chunk) => {
256
- data += chunk;
257
- });
258
-
259
- response.on('end', () => {
260
- try {
261
- resolve(JSON.parse(data));
262
- } catch (e) {
263
- reject(new Error(`Failed to parse Airtable response: ${e.message}`));
264
- }
265
- });
266
- }).on('error', (error) => {
267
- reject(new Error(`Airtable API request failed: ${error.message}`));
268
- });
269
- });
270
- }
271
-
272
- // Start the server on port 8010
273
- const PORT = 8010;
639
+ // Start server
640
+ const PORT = process.env.PORT || 8010;
274
641
  server.listen(PORT, () => {
275
- console.log(`Airtable MCP server running at http://localhost:${PORT}/mcp`);
642
+ log(LOG_LEVELS.INFO, `Enhanced Airtable MCP server v1.4.0 running at http://localhost:${PORT}/mcp`);
276
643
  console.log(`For Claude, use this URL: http://localhost:${PORT}/mcp`);
277
- });
644
+ });
645
+
646
+ // Graceful shutdown
647
+ process.on('SIGINT', () => {
648
+ log(LOG_LEVELS.INFO, 'Shutting down server...');
649
+ server.close(() => {
650
+ log(LOG_LEVELS.INFO, 'Server stopped');
651
+ process.exit(0);
652
+ });
653
+ });