@rashidazarang/airtable-mcp 1.2.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.md +38 -0
- package/.github/ISSUE_TEMPLATE/custom.md +10 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/CLAUDE_INTEGRATION.md +109 -0
- package/CONTRIBUTING.md +81 -0
- package/Dockerfile +39 -0
- package/INSTALLATION.md +183 -0
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/RELEASE_NOTES_v1.2.0.md +50 -0
- package/airtable-mcp-1.1.0.tgz +0 -0
- package/airtable_mcp/__init__.py +5 -0
- package/airtable_mcp/src/server.py +329 -0
- package/bin/airtable-crud-cli.js +445 -0
- package/bin/airtable-mcp.js +44 -0
- package/examples/airtable-crud-example.js +203 -0
- package/examples/building-mcp.md +6666 -0
- package/examples/claude_config.json +4 -0
- package/examples/env-demo.js +172 -0
- package/examples/example-tasks-update.json +23 -0
- package/examples/example-tasks.json +26 -0
- package/examples/example_usage.md +124 -0
- package/examples/sample-transform.js +76 -0
- package/examples/windsurf_mcp_config.json +17 -0
- package/index.js +179 -0
- package/inspector.py +148 -0
- package/inspector_server.py +301 -0
- package/package.json +40 -0
- package/publish-steps.txt +27 -0
- package/rashidazarang-airtable-mcp-1.1.0.tgz +0 -0
- package/rashidazarang-airtable-mcp-1.2.0.tgz +0 -0
- package/requirements.txt +10 -0
- package/setup.py +29 -0
- package/smithery.yaml +41 -0
- package/test_client.py +63 -0
package/inspector.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP Tool Inspector
|
|
4
|
+
-----------------
|
|
5
|
+
A simple script to list tools in a format Smithery can understand
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
# Define the tools manually
|
|
10
|
+
tools = [
|
|
11
|
+
{
|
|
12
|
+
"name": "list_bases",
|
|
13
|
+
"description": "List all accessible Airtable bases",
|
|
14
|
+
"parameters": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {},
|
|
17
|
+
"required": []
|
|
18
|
+
},
|
|
19
|
+
"returns": {
|
|
20
|
+
"type": "string"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "list_tables",
|
|
25
|
+
"description": "List all tables in the specified base or the default base",
|
|
26
|
+
"parameters": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"base_id": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Optional base ID to use instead of the default"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": []
|
|
35
|
+
},
|
|
36
|
+
"returns": {
|
|
37
|
+
"type": "string"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "list_records",
|
|
42
|
+
"description": "List records from a table with optional filtering",
|
|
43
|
+
"parameters": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"table_name": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "Name of the table to list records from"
|
|
49
|
+
},
|
|
50
|
+
"max_records": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"description": "Maximum number of records to return (default: 100)"
|
|
53
|
+
},
|
|
54
|
+
"filter_formula": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Optional Airtable formula to filter records"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": ["table_name"]
|
|
60
|
+
},
|
|
61
|
+
"returns": {
|
|
62
|
+
"type": "string"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "get_record",
|
|
67
|
+
"description": "Get a specific record from a table",
|
|
68
|
+
"parameters": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"table_name": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Name of the table"
|
|
74
|
+
},
|
|
75
|
+
"record_id": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "ID of the record to retrieve"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"required": ["table_name", "record_id"]
|
|
81
|
+
},
|
|
82
|
+
"returns": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "create_records",
|
|
88
|
+
"description": "Create records in a table from JSON string",
|
|
89
|
+
"parameters": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"table_name": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "Name of the table"
|
|
95
|
+
},
|
|
96
|
+
"records_json": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"description": "JSON string containing the records to create"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"required": ["table_name", "records_json"]
|
|
102
|
+
},
|
|
103
|
+
"returns": {
|
|
104
|
+
"type": "string"
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "update_records",
|
|
109
|
+
"description": "Update records in a table from JSON string",
|
|
110
|
+
"parameters": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"properties": {
|
|
113
|
+
"table_name": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"description": "Name of the table"
|
|
116
|
+
},
|
|
117
|
+
"records_json": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"description": "JSON string containing the records to update with IDs"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"required": ["table_name", "records_json"]
|
|
123
|
+
},
|
|
124
|
+
"returns": {
|
|
125
|
+
"type": "string"
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "set_base_id",
|
|
130
|
+
"description": "Set the current Airtable base ID",
|
|
131
|
+
"parameters": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"properties": {
|
|
134
|
+
"base_id": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"description": "Base ID to set as the current base"
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"required": ["base_id"]
|
|
140
|
+
},
|
|
141
|
+
"returns": {
|
|
142
|
+
"type": "string"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
# Print the tools as JSON
|
|
148
|
+
print(json.dumps({"tools": tools}, indent=2))
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Airtable MCP Inspector Server
|
|
4
|
+
-----------------------------
|
|
5
|
+
A simple MCP server that implements the Airtable tools
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import requests
|
|
12
|
+
import argparse
|
|
13
|
+
from typing import Optional, Dict, Any, List
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
except ImportError:
|
|
18
|
+
print("Error: MCP SDK not found. Please install with 'pip install mcp'")
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
# Parse command line arguments
|
|
22
|
+
def parse_args():
|
|
23
|
+
parser = argparse.ArgumentParser(description="Airtable MCP Server")
|
|
24
|
+
parser.add_argument("--token", dest="api_token", help="Airtable Personal Access Token")
|
|
25
|
+
parser.add_argument("--base", dest="base_id", help="Airtable Base ID")
|
|
26
|
+
parser.add_argument("--config", dest="config_json", help="Configuration as JSON (for Smithery integration)")
|
|
27
|
+
return parser.parse_args()
|
|
28
|
+
|
|
29
|
+
# Set up logging
|
|
30
|
+
logging.basicConfig(level=logging.INFO)
|
|
31
|
+
logger = logging.getLogger("airtable-mcp")
|
|
32
|
+
|
|
33
|
+
# Parse arguments
|
|
34
|
+
args = parse_args()
|
|
35
|
+
|
|
36
|
+
# Handle config JSON from Smithery if provided
|
|
37
|
+
config = {}
|
|
38
|
+
if args.config_json:
|
|
39
|
+
try:
|
|
40
|
+
# Strip any trailing quotes or backslashes that might be present
|
|
41
|
+
config_str = args.config_json.rstrip('\\"')
|
|
42
|
+
# Additional sanitization for JSON format
|
|
43
|
+
config_str = config_str.strip()
|
|
44
|
+
# Handle escaped quotes
|
|
45
|
+
if config_str.startswith('"') and config_str.endswith('"'):
|
|
46
|
+
config_str = config_str[1:-1]
|
|
47
|
+
# Fix escaped quotes within JSON
|
|
48
|
+
config_str = config_str.replace('\\"', '"')
|
|
49
|
+
# Replace escaped backslashes
|
|
50
|
+
config_str = config_str.replace('\\\\', '\\')
|
|
51
|
+
|
|
52
|
+
logger.info(f"Parsing sanitized config: {config_str}")
|
|
53
|
+
config = json.loads(config_str)
|
|
54
|
+
logger.info(f"Successfully parsed config: {config}")
|
|
55
|
+
except json.JSONDecodeError as e:
|
|
56
|
+
logger.error(f"Failed to parse config JSON: {e}")
|
|
57
|
+
logger.error(f"Raw config string: {args.config_json}")
|
|
58
|
+
# Try one more approach - sometimes config is double-quoted JSON
|
|
59
|
+
try:
|
|
60
|
+
# Try to interpret as Python string literal
|
|
61
|
+
import ast
|
|
62
|
+
literal_str = ast.literal_eval(f"'''{args.config_json}'''")
|
|
63
|
+
config = json.loads(literal_str)
|
|
64
|
+
logger.info(f"Successfully parsed config using ast: {config}")
|
|
65
|
+
except Exception as ast_error:
|
|
66
|
+
logger.error(f"Failed alternate parsing method: {ast_error}")
|
|
67
|
+
|
|
68
|
+
# Create MCP server
|
|
69
|
+
app = FastMCP("Airtable Tools")
|
|
70
|
+
|
|
71
|
+
# Get token from arguments, config, or environment
|
|
72
|
+
token = args.api_token or config.get("airtable_token", "") or os.environ.get("AIRTABLE_PERSONAL_ACCESS_TOKEN", "")
|
|
73
|
+
# Clean up token if it has trailing quote
|
|
74
|
+
if token and token.endswith('"'):
|
|
75
|
+
token = token[:-1]
|
|
76
|
+
|
|
77
|
+
base_id = args.base_id or config.get("base_id", "") or os.environ.get("AIRTABLE_BASE_ID", "")
|
|
78
|
+
|
|
79
|
+
if not token:
|
|
80
|
+
logger.warning("No Airtable API token provided. Use --token, --config, or set AIRTABLE_PERSONAL_ACCESS_TOKEN environment variable.")
|
|
81
|
+
else:
|
|
82
|
+
logger.info(f"Using Airtable token: {token[:5]}...{token[-5:]}")
|
|
83
|
+
|
|
84
|
+
if base_id:
|
|
85
|
+
logger.info(f"Using base ID: {base_id}")
|
|
86
|
+
else:
|
|
87
|
+
logger.warning("No base ID provided. Use --base, --config, or set AIRTABLE_BASE_ID environment variable.")
|
|
88
|
+
|
|
89
|
+
# Helper functions for Airtable API calls
|
|
90
|
+
async def api_call(endpoint, method="GET", data=None, params=None):
|
|
91
|
+
"""Make an Airtable API call"""
|
|
92
|
+
if not token:
|
|
93
|
+
return {"error": "No Airtable API token provided. Use --token, --config, or set AIRTABLE_PERSONAL_ACCESS_TOKEN environment variable."}
|
|
94
|
+
|
|
95
|
+
headers = {
|
|
96
|
+
"Authorization": f"Bearer {token}",
|
|
97
|
+
"Content-Type": "application/json"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
url = f"https://api.airtable.com/v0/{endpoint}"
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if method == "GET":
|
|
104
|
+
response = requests.get(url, headers=headers, params=params)
|
|
105
|
+
elif method == "POST":
|
|
106
|
+
response = requests.post(url, headers=headers, json=data)
|
|
107
|
+
elif method == "PATCH":
|
|
108
|
+
response = requests.patch(url, headers=headers, json=data)
|
|
109
|
+
elif method == "DELETE":
|
|
110
|
+
response = requests.delete(url, headers=headers, params=params)
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
113
|
+
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
return response.json()
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error(f"API call error: {str(e)}")
|
|
118
|
+
return {"error": str(e)}
|
|
119
|
+
|
|
120
|
+
# Define MCP tool functions
|
|
121
|
+
@app.tool()
|
|
122
|
+
async def list_bases() -> str:
|
|
123
|
+
"""List all accessible Airtable bases"""
|
|
124
|
+
if not token:
|
|
125
|
+
return "Please provide an Airtable API token to list your bases."
|
|
126
|
+
|
|
127
|
+
result = await api_call("meta/bases")
|
|
128
|
+
|
|
129
|
+
if "error" in result:
|
|
130
|
+
return f"Error: {result['error']}"
|
|
131
|
+
|
|
132
|
+
bases = result.get("bases", [])
|
|
133
|
+
if not bases:
|
|
134
|
+
return "No bases found accessible with your token."
|
|
135
|
+
|
|
136
|
+
base_list = [f"{i+1}. {base['name']} (ID: {base['id']})" for i, base in enumerate(bases)]
|
|
137
|
+
return "Available bases:\n" + "\n".join(base_list)
|
|
138
|
+
|
|
139
|
+
@app.tool()
|
|
140
|
+
async def list_tables(base_id_param: Optional[str] = None) -> str:
|
|
141
|
+
"""List all tables in the specified base or the default base"""
|
|
142
|
+
global base_id
|
|
143
|
+
current_base = base_id_param or base_id
|
|
144
|
+
|
|
145
|
+
if not token:
|
|
146
|
+
return "Please provide an Airtable API token to list tables."
|
|
147
|
+
|
|
148
|
+
if not current_base:
|
|
149
|
+
return "Error: No base ID provided. Please specify a base_id or set AIRTABLE_BASE_ID environment variable."
|
|
150
|
+
|
|
151
|
+
result = await api_call(f"meta/bases/{current_base}/tables")
|
|
152
|
+
|
|
153
|
+
if "error" in result:
|
|
154
|
+
return f"Error: {result['error']}"
|
|
155
|
+
|
|
156
|
+
tables = result.get("tables", [])
|
|
157
|
+
if not tables:
|
|
158
|
+
return "No tables found in this base."
|
|
159
|
+
|
|
160
|
+
table_list = [f"{i+1}. {table['name']} (ID: {table['id']}, Fields: {len(table.get('fields', []))})"
|
|
161
|
+
for i, table in enumerate(tables)]
|
|
162
|
+
return "Tables in this base:\n" + "\n".join(table_list)
|
|
163
|
+
|
|
164
|
+
@app.tool()
|
|
165
|
+
async def list_records(table_name: str, max_records: Optional[int] = 100, filter_formula: Optional[str] = None) -> str:
|
|
166
|
+
"""List records from a table with optional filtering"""
|
|
167
|
+
if not token:
|
|
168
|
+
return "Please provide an Airtable API token to list records."
|
|
169
|
+
|
|
170
|
+
if not base_id:
|
|
171
|
+
return "Error: No base ID set. Please use --base or set AIRTABLE_BASE_ID environment variable."
|
|
172
|
+
|
|
173
|
+
params = {"maxRecords": max_records}
|
|
174
|
+
|
|
175
|
+
if filter_formula:
|
|
176
|
+
params["filterByFormula"] = filter_formula
|
|
177
|
+
|
|
178
|
+
result = await api_call(f"{base_id}/{table_name}", params=params)
|
|
179
|
+
|
|
180
|
+
if "error" in result:
|
|
181
|
+
return f"Error: {result['error']}"
|
|
182
|
+
|
|
183
|
+
records = result.get("records", [])
|
|
184
|
+
if not records:
|
|
185
|
+
return "No records found in this table."
|
|
186
|
+
|
|
187
|
+
# Format the records for display
|
|
188
|
+
formatted_records = []
|
|
189
|
+
for i, record in enumerate(records):
|
|
190
|
+
record_id = record.get("id", "unknown")
|
|
191
|
+
fields = record.get("fields", {})
|
|
192
|
+
field_text = ", ".join([f"{k}: {v}" for k, v in fields.items()])
|
|
193
|
+
formatted_records.append(f"{i+1}. ID: {record_id} - {field_text}")
|
|
194
|
+
|
|
195
|
+
return "Records:\n" + "\n".join(formatted_records)
|
|
196
|
+
|
|
197
|
+
@app.tool()
|
|
198
|
+
async def get_record(table_name: str, record_id: str) -> str:
|
|
199
|
+
"""Get a specific record from a table"""
|
|
200
|
+
if not token:
|
|
201
|
+
return "Please provide an Airtable API token to get records."
|
|
202
|
+
|
|
203
|
+
if not base_id:
|
|
204
|
+
return "Error: No base ID set. Please set AIRTABLE_BASE_ID environment variable."
|
|
205
|
+
|
|
206
|
+
result = await api_call(f"{base_id}/{table_name}/{record_id}")
|
|
207
|
+
|
|
208
|
+
if "error" in result:
|
|
209
|
+
return f"Error: {result['error']}"
|
|
210
|
+
|
|
211
|
+
fields = result.get("fields", {})
|
|
212
|
+
if not fields:
|
|
213
|
+
return f"Record {record_id} found but contains no fields."
|
|
214
|
+
|
|
215
|
+
# Format the fields for display
|
|
216
|
+
formatted_fields = []
|
|
217
|
+
for key, value in fields.items():
|
|
218
|
+
formatted_fields.append(f"{key}: {value}")
|
|
219
|
+
|
|
220
|
+
return f"Record ID: {record_id}\n" + "\n".join(formatted_fields)
|
|
221
|
+
|
|
222
|
+
@app.tool()
|
|
223
|
+
async def create_records(table_name: str, records_json: str) -> str:
|
|
224
|
+
"""Create records in a table from JSON string"""
|
|
225
|
+
if not token:
|
|
226
|
+
return "Please provide an Airtable API token to create records."
|
|
227
|
+
|
|
228
|
+
if not base_id:
|
|
229
|
+
return "Error: No base ID set. Please set AIRTABLE_BASE_ID environment variable."
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
records_data = json.loads(records_json)
|
|
233
|
+
|
|
234
|
+
# Format the records for Airtable API
|
|
235
|
+
if not isinstance(records_data, list):
|
|
236
|
+
records_data = [records_data]
|
|
237
|
+
|
|
238
|
+
records = [{"fields": record} for record in records_data]
|
|
239
|
+
|
|
240
|
+
data = {"records": records}
|
|
241
|
+
result = await api_call(f"{base_id}/{table_name}", method="POST", data=data)
|
|
242
|
+
|
|
243
|
+
if "error" in result:
|
|
244
|
+
return f"Error: {result['error']}"
|
|
245
|
+
|
|
246
|
+
created_records = result.get("records", [])
|
|
247
|
+
return f"Successfully created {len(created_records)} records."
|
|
248
|
+
|
|
249
|
+
except json.JSONDecodeError:
|
|
250
|
+
return "Error: Invalid JSON format in records_json parameter."
|
|
251
|
+
except Exception as e:
|
|
252
|
+
return f"Error creating records: {str(e)}"
|
|
253
|
+
|
|
254
|
+
@app.tool()
|
|
255
|
+
async def update_records(table_name: str, records_json: str) -> str:
|
|
256
|
+
"""Update records in a table from JSON string"""
|
|
257
|
+
if not token:
|
|
258
|
+
return "Please provide an Airtable API token to update records."
|
|
259
|
+
|
|
260
|
+
if not base_id:
|
|
261
|
+
return "Error: No base ID set. Please set AIRTABLE_BASE_ID environment variable."
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
records_data = json.loads(records_json)
|
|
265
|
+
|
|
266
|
+
# Format the records for Airtable API
|
|
267
|
+
if not isinstance(records_data, list):
|
|
268
|
+
records_data = [records_data]
|
|
269
|
+
|
|
270
|
+
records = []
|
|
271
|
+
for record in records_data:
|
|
272
|
+
if "id" not in record:
|
|
273
|
+
return "Error: Each record must have an 'id' field."
|
|
274
|
+
|
|
275
|
+
rec_id = record.pop("id")
|
|
276
|
+
fields = record.get("fields", record) # Support both {id, fields} format and direct fields
|
|
277
|
+
records.append({"id": rec_id, "fields": fields})
|
|
278
|
+
|
|
279
|
+
data = {"records": records}
|
|
280
|
+
result = await api_call(f"{base_id}/{table_name}", method="PATCH", data=data)
|
|
281
|
+
|
|
282
|
+
if "error" in result:
|
|
283
|
+
return f"Error: {result['error']}"
|
|
284
|
+
|
|
285
|
+
updated_records = result.get("records", [])
|
|
286
|
+
return f"Successfully updated {len(updated_records)} records."
|
|
287
|
+
|
|
288
|
+
except json.JSONDecodeError:
|
|
289
|
+
return "Error: Invalid JSON format in records_json parameter."
|
|
290
|
+
except Exception as e:
|
|
291
|
+
return f"Error updating records: {str(e)}"
|
|
292
|
+
|
|
293
|
+
@app.tool()
|
|
294
|
+
async def set_base_id(base_id_param: str) -> str:
|
|
295
|
+
"""Set the current Airtable base ID"""
|
|
296
|
+
global base_id
|
|
297
|
+
base_id = base_id_param
|
|
298
|
+
return f"Base ID set to: {base_id}"
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
app.run()
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rashidazarang/airtable-mcp",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Airtable MCP for AI tools - compatible with MCP SDK 1.4.1+, includes compatibility fixes for Claude and Windsurf",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"airtable-mcp": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"airtable",
|
|
14
|
+
"mcp",
|
|
15
|
+
"ai",
|
|
16
|
+
"claude",
|
|
17
|
+
"anthropic",
|
|
18
|
+
"cursor",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"smithery",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"windsurf"
|
|
23
|
+
],
|
|
24
|
+
"author": "Rashid Azarang",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/rashidazarang/airtable-mcp.git"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/rashidazarang/airtable-mcp/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/rashidazarang/airtable-mcp#readme",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=14.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"dotenv": "^16.0.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Steps to Publish to npm
|
|
2
|
+
|
|
3
|
+
## 1. Login to npm
|
|
4
|
+
```
|
|
5
|
+
npm login
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## 2. Publish the package
|
|
9
|
+
```
|
|
10
|
+
npm publish --access public
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 3. Create GitHub Release
|
|
14
|
+
1. Go to https://github.com/rashidazarang/airtable-mcp/releases
|
|
15
|
+
2. Click "Draft a new release"
|
|
16
|
+
3. Select the tag: v1.2.0
|
|
17
|
+
4. Title: "v1.2.0: Claude & Windsurf Compatibility"
|
|
18
|
+
5. Copy and paste the content from RELEASE_NOTES_v1.2.0.md
|
|
19
|
+
6. Attach the file: rashidazarang-airtable-mcp-1.2.0.tgz
|
|
20
|
+
7. Click "Publish release"
|
|
21
|
+
|
|
22
|
+
## 4. Update Smithery
|
|
23
|
+
If you use Smithery, make sure to update the repository there:
|
|
24
|
+
1. Login to Smithery
|
|
25
|
+
2. Go to your package
|
|
26
|
+
3. Update the version information
|
|
27
|
+
4. Publish the update
|
|
Binary file
|
|
Binary file
|
package/requirements.txt
ADDED
package/setup.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from setuptools import setup, find_packages
|
|
4
|
+
|
|
5
|
+
with open("README.md", "r", encoding="utf-8") as fh:
|
|
6
|
+
long_description = fh.read()
|
|
7
|
+
|
|
8
|
+
with open("requirements.txt", "r", encoding="utf-8") as req_file:
|
|
9
|
+
requirements = req_file.read().splitlines()
|
|
10
|
+
|
|
11
|
+
setup(
|
|
12
|
+
name="airtable-mcp",
|
|
13
|
+
version="1.1.0",
|
|
14
|
+
author="Rashid Azarang",
|
|
15
|
+
author_email="rashidazarang@gmail.com",
|
|
16
|
+
description="Airtable MCP for AI tools - updated to work with MCP SDK 1.4.1+",
|
|
17
|
+
long_description=long_description,
|
|
18
|
+
long_description_content_type="text/markdown",
|
|
19
|
+
url="https://github.com/rashidazarang/airtable-mcp",
|
|
20
|
+
packages=find_packages(),
|
|
21
|
+
install_requires=requirements,
|
|
22
|
+
classifiers=[
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
],
|
|
27
|
+
python_requires=">=3.10",
|
|
28
|
+
include_package_data=True,
|
|
29
|
+
)
|
package/smithery.yaml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Smithery.ai configuration
|
|
2
|
+
name: "@rashidazarang/airtable-mcp"
|
|
3
|
+
version: "1.0.0"
|
|
4
|
+
description: "Connect your AI tools directly to Airtable. Query, create, update, and delete records using natural language. Features include base management, table operations, schema manipulation, record filtering, and data migration—all through a standardized MCP interface compatible with Cursor, Claude Code, Cline, Zed, and other Claude-powered editors."
|
|
5
|
+
|
|
6
|
+
startCommand:
|
|
7
|
+
type: stdio
|
|
8
|
+
configSchema:
|
|
9
|
+
type: object
|
|
10
|
+
properties:
|
|
11
|
+
airtable_token:
|
|
12
|
+
type: string
|
|
13
|
+
description: "Your Airtable Personal Access Token"
|
|
14
|
+
base_id:
|
|
15
|
+
type: string
|
|
16
|
+
description: "Your default Airtable base ID (optional)"
|
|
17
|
+
required: ["airtable_token"]
|
|
18
|
+
commandFunction: |
|
|
19
|
+
(config) => {
|
|
20
|
+
// Pass config as a JSON string to the inspector_server.py
|
|
21
|
+
const configStr = JSON.stringify(config);
|
|
22
|
+
return {
|
|
23
|
+
command: "python3.10",
|
|
24
|
+
args: ["inspector_server.py", "--config", configStr],
|
|
25
|
+
env: {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
listTools:
|
|
30
|
+
command: "python3.10"
|
|
31
|
+
args: ["inspector.py"]
|
|
32
|
+
env: {}
|
|
33
|
+
|
|
34
|
+
build:
|
|
35
|
+
dockerfile: "Dockerfile"
|
|
36
|
+
|
|
37
|
+
metadata:
|
|
38
|
+
author: "Rashid Azarang"
|
|
39
|
+
license: "MIT"
|
|
40
|
+
repository: "https://github.com/rashidazarang/airtable-mcp"
|
|
41
|
+
homepage: "https://github.com/rashidazarang/airtable-mcp#readme"
|
package/test_client.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Simple test client for Airtable MCP
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from typing import Dict, Any
|
|
11
|
+
|
|
12
|
+
# Define the token and base ID
|
|
13
|
+
TOKEN = "patnWSCSCmnsqeQ4I.2a3603372f6df67e51f9fe553012192019f2d81c3eab0f94ebd702d7fb63e338"
|
|
14
|
+
BASE_ID = "appi7fWMQcB3BNzPs"
|
|
15
|
+
|
|
16
|
+
# Helper function to directly make Airtable API calls
|
|
17
|
+
def api_call(endpoint, token=TOKEN):
|
|
18
|
+
"""Make a direct Airtable API call to test API access"""
|
|
19
|
+
import requests
|
|
20
|
+
headers = {
|
|
21
|
+
"Authorization": f"Bearer {token}",
|
|
22
|
+
"Content-Type": "application/json"
|
|
23
|
+
}
|
|
24
|
+
url = f"https://api.airtable.com/v0/{endpoint}"
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
response = requests.get(url, headers=headers)
|
|
28
|
+
response.raise_for_status()
|
|
29
|
+
return response.json()
|
|
30
|
+
except Exception as e:
|
|
31
|
+
print(f"API error: {str(e)}")
|
|
32
|
+
return {"error": str(e)}
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
# Instead of using the MCP, let's directly test the Airtable API
|
|
36
|
+
print("Testing direct API access...")
|
|
37
|
+
|
|
38
|
+
# List bases
|
|
39
|
+
print("\nListing bases:")
|
|
40
|
+
result = api_call("meta/bases")
|
|
41
|
+
if "error" in result:
|
|
42
|
+
print(f"Error: {result['error']}")
|
|
43
|
+
else:
|
|
44
|
+
bases = result.get("bases", [])
|
|
45
|
+
for i, base in enumerate(bases):
|
|
46
|
+
print(f"{i+1}. {base['name']} (ID: {base['id']})")
|
|
47
|
+
|
|
48
|
+
# List tables in the specified base
|
|
49
|
+
print(f"\nListing tables in base {BASE_ID}:")
|
|
50
|
+
result = api_call(f"meta/bases/{BASE_ID}/tables")
|
|
51
|
+
if "error" in result:
|
|
52
|
+
print(f"Error: {result['error']}")
|
|
53
|
+
else:
|
|
54
|
+
tables = result.get("tables", [])
|
|
55
|
+
for i, table in enumerate(tables):
|
|
56
|
+
print(f"{i+1}. {table['name']} (ID: {table['id']}, Fields: {len(table.get('fields', []))})")
|
|
57
|
+
# Print fields
|
|
58
|
+
print(" Fields:")
|
|
59
|
+
for field in table.get('fields', []):
|
|
60
|
+
print(f" - {field['name']} ({field['type']})")
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
asyncio.run(main())
|