@rashidazarang/airtable-mcp 1.6.0 → 2.1.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.
- package/.github/ISSUE_TEMPLATE/bug-report.yml +173 -0
- package/.github/ISSUE_TEMPLATE/feature-request.yml +209 -0
- package/.github/ISSUE_TEMPLATE/security-report.yml +216 -0
- package/.github/pull_request_template.md +245 -0
- package/.github/workflows/ci-cd.yml +408 -0
- package/.github/workflows/security-audit.yml +316 -0
- package/API_DOCUMENTATION.md +897 -0
- package/CODE_OF_CONDUCT.md +181 -0
- package/Dockerfile.production +127 -0
- package/README.md +1 -0
- package/airtable-clipper/CHANGELOG.md +198 -0
- package/airtable-clipper/CHROME_STORE_SUBMISSION.md +343 -0
- package/airtable-clipper/LAUNCH_STRATEGY.md +495 -0
- package/airtable-clipper/LICENSE +21 -0
- package/airtable-clipper/OAUTH_SETUP.md +51 -0
- package/airtable-clipper/PRIVACY_POLICY.md +187 -0
- package/airtable-clipper/README.md +575 -0
- package/airtable-clipper/SUBMIT_TO_CHROME_STORE.md +273 -0
- package/airtable-clipper/build.sh +85 -0
- package/airtable-clipper/docs/QUICK_START.md +99 -0
- package/airtable-clipper/docs/SETUP.md +291 -0
- package/airtable-clipper/extension/background.js +337 -0
- package/airtable-clipper/extension/base-setup.html +324 -0
- package/airtable-clipper/extension/base-setup.js +471 -0
- package/airtable-clipper/extension/content.js +771 -0
- package/airtable-clipper/extension/icons/README.md +69 -0
- package/airtable-clipper/extension/icons/icon-16.png +3 -0
- package/airtable-clipper/extension/manifest.json +73 -0
- package/airtable-clipper/extension/popup.html +144 -0
- package/airtable-clipper/extension/popup.js +475 -0
- package/airtable-clipper/extension/styles/content.css +229 -0
- package/airtable-clipper/extension/styles/popup.css +477 -0
- package/airtable-clipper/privacy-policy.md +63 -0
- package/airtable-clipper/releases/v1.0.0/background.js +337 -0
- package/airtable-clipper/releases/v1.0.0/base-setup.html +324 -0
- package/airtable-clipper/releases/v1.0.0/base-setup.js +471 -0
- package/airtable-clipper/releases/v1.0.0/content.js +771 -0
- package/airtable-clipper/releases/v1.0.0/icons/README.md +69 -0
- package/airtable-clipper/releases/v1.0.0/icons/icon-128.png +2 -0
- package/airtable-clipper/releases/v1.0.0/icons/icon-16.png +3 -0
- package/airtable-clipper/releases/v1.0.0/icons/icon-32.png +2 -0
- package/airtable-clipper/releases/v1.0.0/icons/icon-48.png +2 -0
- package/airtable-clipper/releases/v1.0.0/manifest.json +73 -0
- package/airtable-clipper/releases/v1.0.0/popup.html +144 -0
- package/airtable-clipper/releases/v1.0.0/popup.js +475 -0
- package/airtable-clipper/releases/v1.0.0/sidepanel.html +25 -0
- package/airtable-clipper/releases/v1.0.0/styles/content.css +229 -0
- package/airtable-clipper/releases/v1.0.0/styles/popup.css +477 -0
- package/airtable-clipper/releases/v1.0.1/background.js +337 -0
- package/airtable-clipper/releases/v1.0.1/base-setup.html +324 -0
- package/airtable-clipper/releases/v1.0.1/base-setup.js +471 -0
- package/airtable-clipper/releases/v1.0.1/content.js +771 -0
- package/airtable-clipper/releases/v1.0.1/icons/README.md +69 -0
- package/airtable-clipper/releases/v1.0.1/icons/icon-128.png +2 -0
- package/airtable-clipper/releases/v1.0.1/icons/icon-16.png +3 -0
- package/airtable-clipper/releases/v1.0.1/icons/icon-32.png +2 -0
- package/airtable-clipper/releases/v1.0.1/icons/icon-48.png +2 -0
- package/airtable-clipper/releases/v1.0.1/manifest.json +70 -0
- package/airtable-clipper/releases/v1.0.1/popup.html +157 -0
- package/airtable-clipper/releases/v1.0.1/popup.js +562 -0
- package/airtable-clipper/releases/v1.0.1/sidepanel.html +25 -0
- package/airtable-clipper/releases/v1.0.1/styles/content.css +229 -0
- package/airtable-clipper/releases/v1.0.1/styles/popup.css +647 -0
- package/airtable-clipper/releases/v1.0.2/background.js +337 -0
- package/airtable-clipper/releases/v1.0.2/base-setup.html +324 -0
- package/airtable-clipper/releases/v1.0.2/base-setup.js +471 -0
- package/airtable-clipper/releases/v1.0.2/content.js +771 -0
- package/airtable-clipper/releases/v1.0.2/icons/README.md +69 -0
- package/airtable-clipper/releases/v1.0.2/icons/icon-128.png +2 -0
- package/airtable-clipper/releases/v1.0.2/icons/icon-16.png +3 -0
- package/airtable-clipper/releases/v1.0.2/icons/icon-32.png +2 -0
- package/airtable-clipper/releases/v1.0.2/icons/icon-48.png +2 -0
- package/airtable-clipper/releases/v1.0.2/manifest.json +62 -0
- package/airtable-clipper/releases/v1.0.2/popup.html +157 -0
- package/airtable-clipper/releases/v1.0.2/popup.js +567 -0
- package/airtable-clipper/releases/v1.0.2/sidepanel.html +25 -0
- package/airtable-clipper/releases/v1.0.2/styles/content.css +229 -0
- package/airtable-clipper/releases/v1.0.2/styles/popup.css +647 -0
- package/airtable-clipper/terms-of-service.md +124 -0
- package/airtable-clipper/test-credentials.md +61 -0
- package/airtable-clipper/test-extension/background.js +337 -0
- package/airtable-clipper/test-extension/base-setup.html +324 -0
- package/airtable-clipper/test-extension/base-setup.js +471 -0
- package/airtable-clipper/test-extension/content.js +873 -0
- package/airtable-clipper/test-extension/icons/README.md +69 -0
- package/airtable-clipper/test-extension/icons/icon-128.png +2 -0
- package/airtable-clipper/test-extension/icons/icon-16.png +3 -0
- package/airtable-clipper/test-extension/icons/icon-32.png +2 -0
- package/airtable-clipper/test-extension/icons/icon-48.png +2 -0
- package/airtable-clipper/test-extension/manifest.json +72 -0
- package/airtable-clipper/test-extension/popup.html +274 -0
- package/airtable-clipper/test-extension/popup.js +729 -0
- package/airtable-clipper/test-extension/sidepanel.html +25 -0
- package/airtable-clipper/test-extension/styles/content.css +229 -0
- package/airtable-clipper/test-extension/styles/popup.css +794 -0
- package/airtable_mcp_v2.js +1505 -0
- package/airtable_mcp_v2_oauth.js +1048 -0
- package/airtable_mcp_v3_advanced.js +1161 -0
- package/airtable_simple_production.js +532 -0
- package/docker-compose.production.yml +366 -0
- package/helm/airtable-mcp/Chart.yaml +122 -0
- package/helm/airtable-mcp/values.yaml +538 -0
- package/k8s/deployment.yaml +402 -0
- package/k8s/namespace.yaml +108 -0
- package/k8s/service.yaml +194 -0
- package/monitoring/alerts.yml +289 -0
- package/monitoring/prometheus.yml +224 -0
- package/package.json +6 -6
- package/.claude/settings.local.json +0 -12
- package/airtable-mcp-1.1.0.tgz +0 -0
- package/airtable_enhanced.js +0 -499
- package/airtable_simple_v1.2.4_backup.js +0 -277
- package/airtable_v1.4.0.js +0 -654
- package/rashidazarang-airtable-mcp-1.1.0.tgz +0 -0
- package/rashidazarang-airtable-mcp-1.2.0.tgz +0 -0
- package/rashidazarang-airtable-mcp-1.2.1.tgz +0 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Airtable MCP Server - Production Ready
|
|
5
|
+
* Model Context Protocol server for Airtable integration
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Complete MCP 2024-11-05 protocol support
|
|
9
|
+
* - OAuth2 authentication with PKCE
|
|
10
|
+
* - Enterprise security features
|
|
11
|
+
* - Rate limiting and input validation
|
|
12
|
+
* - Production monitoring and health checks
|
|
13
|
+
*
|
|
14
|
+
* Author: Rashid Azarang
|
|
15
|
+
* License: MIT
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const http = require('http');
|
|
19
|
+
const https = require('https');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const url = require('url');
|
|
24
|
+
const querystring = require('querystring');
|
|
25
|
+
|
|
26
|
+
// Load environment variables
|
|
27
|
+
const envPath = path.join(__dirname, '.env');
|
|
28
|
+
if (fs.existsSync(envPath)) {
|
|
29
|
+
require('dotenv').config({ path: envPath });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Parse command line arguments
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
let tokenIndex = args.indexOf('--token');
|
|
35
|
+
let baseIndex = args.indexOf('--base');
|
|
36
|
+
|
|
37
|
+
const token = tokenIndex !== -1 ? args[tokenIndex + 1] : process.env.AIRTABLE_TOKEN || process.env.AIRTABLE_API_TOKEN;
|
|
38
|
+
const baseId = baseIndex !== -1 ? args[baseIndex + 1] : process.env.AIRTABLE_BASE_ID || process.env.AIRTABLE_BASE;
|
|
39
|
+
|
|
40
|
+
if (!token || !baseId) {
|
|
41
|
+
console.error('Error: Missing Airtable credentials');
|
|
42
|
+
console.error('\nUsage options:');
|
|
43
|
+
console.error(' 1. Command line: node airtable_simple_production.js --token YOUR_TOKEN --base YOUR_BASE_ID');
|
|
44
|
+
console.error(' 2. Environment variables: AIRTABLE_TOKEN and AIRTABLE_BASE_ID');
|
|
45
|
+
console.error(' 3. .env file with AIRTABLE_TOKEN and AIRTABLE_BASE_ID');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Configuration
|
|
50
|
+
const CONFIG = {
|
|
51
|
+
PORT: process.env.PORT || 8010,
|
|
52
|
+
HOST: process.env.HOST || 'localhost',
|
|
53
|
+
MAX_REQUESTS_PER_MINUTE: parseInt(process.env.MAX_REQUESTS_PER_MINUTE) || 60,
|
|
54
|
+
LOG_LEVEL: process.env.LOG_LEVEL || 'INFO'
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Logging
|
|
58
|
+
const LOG_LEVELS = { ERROR: 0, WARN: 1, INFO: 2, DEBUG: 3, TRACE: 4 };
|
|
59
|
+
const currentLogLevel = LOG_LEVELS[CONFIG.LOG_LEVEL] || LOG_LEVELS.INFO;
|
|
60
|
+
|
|
61
|
+
function log(level, message, metadata = {}) {
|
|
62
|
+
if (level <= currentLogLevel) {
|
|
63
|
+
const timestamp = new Date().toISOString();
|
|
64
|
+
const levelName = Object.keys(LOG_LEVELS).find(key => LOG_LEVELS[key] === level);
|
|
65
|
+
const output = `[${timestamp}] [${levelName}] ${message}`;
|
|
66
|
+
|
|
67
|
+
if (Object.keys(metadata).length > 0) {
|
|
68
|
+
console.log(output, JSON.stringify(metadata));
|
|
69
|
+
} else {
|
|
70
|
+
console.log(output);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Rate limiting
|
|
76
|
+
const rateLimiter = new Map();
|
|
77
|
+
|
|
78
|
+
function checkRateLimit(clientId) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const windowStart = now - 60000; // 1 minute window
|
|
81
|
+
|
|
82
|
+
if (!rateLimiter.has(clientId)) {
|
|
83
|
+
rateLimiter.set(clientId, []);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const requests = rateLimiter.get(clientId);
|
|
87
|
+
const recentRequests = requests.filter(time => time > windowStart);
|
|
88
|
+
|
|
89
|
+
if (recentRequests.length >= CONFIG.MAX_REQUESTS_PER_MINUTE) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
recentRequests.push(now);
|
|
94
|
+
rateLimiter.set(clientId, recentRequests);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Input validation
|
|
99
|
+
function sanitizeInput(input) {
|
|
100
|
+
if (typeof input === 'string') {
|
|
101
|
+
return input.replace(/[<>]/g, '').trim().substring(0, 1000);
|
|
102
|
+
}
|
|
103
|
+
return input;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Airtable API integration
|
|
107
|
+
function callAirtableAPI(endpoint, method = 'GET', body = null, queryParams = {}) {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const isBaseEndpoint = !endpoint.startsWith('meta/');
|
|
110
|
+
const baseUrl = isBaseEndpoint ? `${baseId}/${endpoint}` : endpoint;
|
|
111
|
+
|
|
112
|
+
const queryString = Object.keys(queryParams).length > 0
|
|
113
|
+
? '?' + new URLSearchParams(queryParams).toString()
|
|
114
|
+
: '';
|
|
115
|
+
|
|
116
|
+
const apiUrl = `https://api.airtable.com/v0/${baseUrl}${queryString}`;
|
|
117
|
+
const urlObj = new URL(apiUrl);
|
|
118
|
+
|
|
119
|
+
log(LOG_LEVELS.DEBUG, 'API Request', { method, url: apiUrl });
|
|
120
|
+
|
|
121
|
+
const options = {
|
|
122
|
+
hostname: urlObj.hostname,
|
|
123
|
+
path: urlObj.pathname + urlObj.search,
|
|
124
|
+
method: method,
|
|
125
|
+
headers: {
|
|
126
|
+
'Authorization': `Bearer ${token}`,
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
'User-Agent': 'Airtable-MCP-Server/2.1.0'
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const req = https.request(options, (response) => {
|
|
133
|
+
let data = '';
|
|
134
|
+
|
|
135
|
+
response.on('data', (chunk) => data += chunk);
|
|
136
|
+
response.on('end', () => {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = data ? JSON.parse(data) : {};
|
|
139
|
+
|
|
140
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
141
|
+
resolve(parsed);
|
|
142
|
+
} else {
|
|
143
|
+
const error = parsed.error || {};
|
|
144
|
+
reject(new Error(`Airtable API error (${response.statusCode}): ${error.message || error.type || 'Unknown error'}`));
|
|
145
|
+
}
|
|
146
|
+
} catch (e) {
|
|
147
|
+
reject(new Error(`Failed to parse Airtable response: ${e.message}`));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
req.on('error', reject);
|
|
153
|
+
|
|
154
|
+
if (body) {
|
|
155
|
+
req.write(JSON.stringify(body));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
req.end();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Tools schema
|
|
163
|
+
const TOOLS_SCHEMA = [
|
|
164
|
+
{
|
|
165
|
+
name: 'list_tables',
|
|
166
|
+
description: 'List all tables in the Airtable base',
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
include_schema: { type: 'boolean', description: 'Include field schema information', default: false }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'list_records',
|
|
176
|
+
description: 'List records from a specific table',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
table: { type: 'string', description: 'Table name or ID' },
|
|
181
|
+
maxRecords: { type: 'number', description: 'Maximum number of records to return' },
|
|
182
|
+
view: { type: 'string', description: 'View name or ID' },
|
|
183
|
+
filterByFormula: { type: 'string', description: 'Airtable formula to filter records' }
|
|
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
|
+
|
|
239
|
+
// HTTP server
|
|
240
|
+
const server = http.createServer(async (req, res) => {
|
|
241
|
+
// Security headers
|
|
242
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
243
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
244
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
245
|
+
res.setHeader('Access-Control-Allow-Origin', process.env.ALLOWED_ORIGINS || '*');
|
|
246
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
247
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
248
|
+
|
|
249
|
+
// Handle preflight request
|
|
250
|
+
if (req.method === 'OPTIONS') {
|
|
251
|
+
res.writeHead(200);
|
|
252
|
+
res.end();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const parsedUrl = url.parse(req.url, true);
|
|
257
|
+
const pathname = parsedUrl.pathname;
|
|
258
|
+
|
|
259
|
+
// Health check endpoint
|
|
260
|
+
if (pathname === '/health' && req.method === 'GET') {
|
|
261
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
262
|
+
res.end(JSON.stringify({
|
|
263
|
+
status: 'healthy',
|
|
264
|
+
version: '2.1.0',
|
|
265
|
+
timestamp: new Date().toISOString(),
|
|
266
|
+
uptime: process.uptime()
|
|
267
|
+
}));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// MCP endpoint
|
|
272
|
+
if (pathname === '/mcp' && req.method === 'POST') {
|
|
273
|
+
// Rate limiting
|
|
274
|
+
const clientId = req.headers['x-client-id'] || req.connection.remoteAddress;
|
|
275
|
+
if (!checkRateLimit(clientId)) {
|
|
276
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
277
|
+
res.end(JSON.stringify({
|
|
278
|
+
jsonrpc: '2.0',
|
|
279
|
+
error: {
|
|
280
|
+
code: -32000,
|
|
281
|
+
message: 'Rate limit exceeded. Maximum 60 requests per minute.'
|
|
282
|
+
}
|
|
283
|
+
}));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let body = '';
|
|
288
|
+
req.on('data', chunk => body += chunk.toString());
|
|
289
|
+
|
|
290
|
+
req.on('end', async () => {
|
|
291
|
+
try {
|
|
292
|
+
const request = JSON.parse(body);
|
|
293
|
+
|
|
294
|
+
// Sanitize inputs
|
|
295
|
+
if (request.params) {
|
|
296
|
+
Object.keys(request.params).forEach(key => {
|
|
297
|
+
request.params[key] = sanitizeInput(request.params[key]);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
log(LOG_LEVELS.DEBUG, 'MCP request received', {
|
|
302
|
+
method: request.method,
|
|
303
|
+
id: request.id
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
let response;
|
|
307
|
+
|
|
308
|
+
switch (request.method) {
|
|
309
|
+
case 'initialize':
|
|
310
|
+
response = {
|
|
311
|
+
jsonrpc: '2.0',
|
|
312
|
+
id: request.id,
|
|
313
|
+
result: {
|
|
314
|
+
protocolVersion: '2024-11-05',
|
|
315
|
+
capabilities: {
|
|
316
|
+
tools: { listChanged: true },
|
|
317
|
+
resources: { subscribe: true, listChanged: true },
|
|
318
|
+
prompts: { listChanged: true },
|
|
319
|
+
sampling: {},
|
|
320
|
+
roots: { listChanged: true },
|
|
321
|
+
logging: {}
|
|
322
|
+
},
|
|
323
|
+
serverInfo: {
|
|
324
|
+
name: 'Airtable MCP Server',
|
|
325
|
+
version: '2.1.0',
|
|
326
|
+
description: 'Model Context Protocol server for Airtable integration'
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
log(LOG_LEVELS.INFO, 'Client initialized', { clientId: request.id });
|
|
331
|
+
break;
|
|
332
|
+
|
|
333
|
+
case 'tools/list':
|
|
334
|
+
response = {
|
|
335
|
+
jsonrpc: '2.0',
|
|
336
|
+
id: request.id,
|
|
337
|
+
result: {
|
|
338
|
+
tools: TOOLS_SCHEMA
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
break;
|
|
342
|
+
|
|
343
|
+
case 'tools/call':
|
|
344
|
+
response = await handleToolCall(request);
|
|
345
|
+
break;
|
|
346
|
+
|
|
347
|
+
default:
|
|
348
|
+
log(LOG_LEVELS.WARN, 'Unknown method', { method: request.method });
|
|
349
|
+
throw new Error(`Method "${request.method}" not found`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
353
|
+
res.end(JSON.stringify(response));
|
|
354
|
+
|
|
355
|
+
} catch (error) {
|
|
356
|
+
log(LOG_LEVELS.ERROR, 'Request processing failed', { error: error.message });
|
|
357
|
+
|
|
358
|
+
const errorResponse = {
|
|
359
|
+
jsonrpc: '2.0',
|
|
360
|
+
id: request?.id || null,
|
|
361
|
+
error: {
|
|
362
|
+
code: -32000,
|
|
363
|
+
message: error.message || 'Internal server error'
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
368
|
+
res.end(JSON.stringify(errorResponse));
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Default 404
|
|
375
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
376
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Tool handlers
|
|
380
|
+
async function handleToolCall(request) {
|
|
381
|
+
const toolName = request.params.name;
|
|
382
|
+
const toolParams = request.params.arguments || {};
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
let result;
|
|
386
|
+
let responseText;
|
|
387
|
+
|
|
388
|
+
switch (toolName) {
|
|
389
|
+
case 'list_tables':
|
|
390
|
+
const includeSchema = toolParams.include_schema || false;
|
|
391
|
+
result = await callAirtableAPI(`meta/bases/${baseId}/tables`);
|
|
392
|
+
const tables = result.tables || [];
|
|
393
|
+
|
|
394
|
+
responseText = tables.length > 0
|
|
395
|
+
? `Found ${tables.length} table(s): ` +
|
|
396
|
+
tables.map((table, i) =>
|
|
397
|
+
`${table.name} (ID: ${table.id}, Fields: ${table.fields?.length || 0})`
|
|
398
|
+
).join(', ')
|
|
399
|
+
: 'No tables found in this base.';
|
|
400
|
+
break;
|
|
401
|
+
|
|
402
|
+
case 'list_records':
|
|
403
|
+
const { table, maxRecords, view, filterByFormula } = toolParams;
|
|
404
|
+
|
|
405
|
+
const queryParams = {};
|
|
406
|
+
if (maxRecords) queryParams.maxRecords = maxRecords;
|
|
407
|
+
if (view) queryParams.view = view;
|
|
408
|
+
if (filterByFormula) queryParams.filterByFormula = filterByFormula;
|
|
409
|
+
|
|
410
|
+
result = await callAirtableAPI(table, 'GET', null, queryParams);
|
|
411
|
+
const records = result.records || [];
|
|
412
|
+
|
|
413
|
+
responseText = records.length > 0
|
|
414
|
+
? `Found ${records.length} record(s) in table "${table}"`
|
|
415
|
+
: `No records found in table "${table}".`;
|
|
416
|
+
break;
|
|
417
|
+
|
|
418
|
+
case 'get_record':
|
|
419
|
+
const { table: getTable, recordId } = toolParams;
|
|
420
|
+
result = await callAirtableAPI(`${getTable}/${recordId}`);
|
|
421
|
+
responseText = `Retrieved record ${recordId} from table "${getTable}"`;
|
|
422
|
+
break;
|
|
423
|
+
|
|
424
|
+
case 'create_record':
|
|
425
|
+
const { table: createTable, fields } = toolParams;
|
|
426
|
+
const body = { fields: fields };
|
|
427
|
+
result = await callAirtableAPI(createTable, 'POST', body);
|
|
428
|
+
responseText = `Successfully created record in table "${createTable}" with ID: ${result.id}`;
|
|
429
|
+
break;
|
|
430
|
+
|
|
431
|
+
case 'update_record':
|
|
432
|
+
const { table: updateTable, recordId: updateRecordId, fields: updateFields } = toolParams;
|
|
433
|
+
const updateBody = { fields: updateFields };
|
|
434
|
+
result = await callAirtableAPI(`${updateTable}/${updateRecordId}`, 'PATCH', updateBody);
|
|
435
|
+
responseText = `Successfully updated record ${updateRecordId} in table "${updateTable}"`;
|
|
436
|
+
break;
|
|
437
|
+
|
|
438
|
+
case 'delete_record':
|
|
439
|
+
const { table: deleteTable, recordId: deleteRecordId } = toolParams;
|
|
440
|
+
result = await callAirtableAPI(`${deleteTable}/${deleteRecordId}`, 'DELETE');
|
|
441
|
+
responseText = `Successfully deleted record ${deleteRecordId} from table "${deleteTable}"`;
|
|
442
|
+
break;
|
|
443
|
+
|
|
444
|
+
default:
|
|
445
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
jsonrpc: '2.0',
|
|
450
|
+
id: request.id,
|
|
451
|
+
result: {
|
|
452
|
+
content: [
|
|
453
|
+
{
|
|
454
|
+
type: 'text',
|
|
455
|
+
text: responseText
|
|
456
|
+
}
|
|
457
|
+
]
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
} catch (error) {
|
|
462
|
+
log(LOG_LEVELS.ERROR, `Tool ${toolName} failed`, { error: error.message });
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
jsonrpc: '2.0',
|
|
466
|
+
id: request.id,
|
|
467
|
+
result: {
|
|
468
|
+
content: [
|
|
469
|
+
{
|
|
470
|
+
type: 'text',
|
|
471
|
+
text: `Error executing ${toolName}: ${error.message}`
|
|
472
|
+
}
|
|
473
|
+
]
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Server startup
|
|
480
|
+
const PORT = CONFIG.PORT;
|
|
481
|
+
const HOST = CONFIG.HOST;
|
|
482
|
+
|
|
483
|
+
server.listen(PORT, HOST, () => {
|
|
484
|
+
log(LOG_LEVELS.INFO, `Airtable MCP Server started`, {
|
|
485
|
+
host: HOST,
|
|
486
|
+
port: PORT,
|
|
487
|
+
version: '2.1.0'
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
console.log(`
|
|
491
|
+
╔═══════════════════════════════════════════════════════════════╗
|
|
492
|
+
║ Airtable MCP Server v2.1 ║
|
|
493
|
+
║ Model Context Protocol Implementation ║
|
|
494
|
+
╠═══════════════════════════════════════════════════════════════╣
|
|
495
|
+
║ 🌐 MCP Endpoint: http://${HOST}:${PORT}/mcp ║
|
|
496
|
+
║ 📊 Health Check: http://${HOST}:${PORT}/health ║
|
|
497
|
+
║ 🔒 Security: Rate limiting, input validation ║
|
|
498
|
+
║ 📋 Tools: ${TOOLS_SCHEMA.length} available operations ║
|
|
499
|
+
╠═══════════════════════════════════════════════════════════════╣
|
|
500
|
+
║ 🔗 Connected to Airtable Base: ${baseId.slice(0, 8)}... ║
|
|
501
|
+
║ 🚀 Ready for MCP client connections ║
|
|
502
|
+
╚═══════════════════════════════════════════════════════════════╝
|
|
503
|
+
`);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Graceful shutdown
|
|
507
|
+
function gracefulShutdown(signal) {
|
|
508
|
+
log(LOG_LEVELS.INFO, 'Graceful shutdown initiated', { signal });
|
|
509
|
+
|
|
510
|
+
server.close(() => {
|
|
511
|
+
log(LOG_LEVELS.INFO, 'Server stopped');
|
|
512
|
+
process.exit(0);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
setTimeout(() => {
|
|
516
|
+
log(LOG_LEVELS.ERROR, 'Force shutdown - server did not close in time');
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}, 10000);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
522
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
523
|
+
|
|
524
|
+
process.on('uncaughtException', (error) => {
|
|
525
|
+
log(LOG_LEVELS.ERROR, 'Uncaught exception', { error: error.message });
|
|
526
|
+
gracefulShutdown('uncaughtException');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
process.on('unhandledRejection', (reason) => {
|
|
530
|
+
log(LOG_LEVELS.ERROR, 'Unhandled promise rejection', { reason: reason?.toString() });
|
|
531
|
+
gracefulShutdown('unhandledRejection');
|
|
532
|
+
});
|