Skip to content

Commit 54b16d6

Browse files
ARHAEEMclaude
andcommitted
fix(mcp-server): resolve bug report — v2.2.0
Root-cause fixes verified against real Airtable internal API capture (dev-tools/captures/2026-04-17T15-23-11-507Z): Bug 2 — create_field rejected "url": internal API uses type="text" + typeOptions.validatorName="url". Added auto-mapping for url/email/phone/phoneNumber aliases. Bug 3 — create_field rejected "dateTime": internal API uses type="date" with isDateTime=true and FLAT string formats (not {name:"..."}). Added normalizer that also flattens public-REST-style option objects. Bug 5 — delete_field returned the full 268KB dependency graph. Response now carries a compact {viewGroupings, viewSorts, viewFilters, fields, other, edgeCount} summary; raw graph still available. "is" / "isNot" on text+singleSelect fields: Airtable's UI sends "=" / "!=" — the API doesn't recognize "is". Auto-normalized client-side. isAnyOf with single-element arrays collapses to "=" + scalar value. New tools (all endpoints verified in capture): delete_table, create_table, rename_table — POST table/{id}/(destroy|create|updateName) get_view — extracts filter/sort/group state from schema (no new endpoint) Enhancements: update_view_filters accepts operation:"append" to add conditions without reconstructing existing filter payload (reads via getView first). filters:null clears all filters (matches UI behavior). Tool descriptions rewritten with verified operator tables per field type and worked examples; note that singleSelect values are selXXX IDs. New categories table-write / table-destructive; safe-write and full profiles updated. 123/123 tests pass (96 MCP + 19 extension + 8 webview). MCP server bumped to 2.2.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70cc78d commit 54b16d6

File tree

6 files changed

+842
-58
lines changed

6 files changed

+842
-58
lines changed

packages/mcp-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "airtable-user-mcp",
3-
"version": "2.1.6",
3+
"version": "2.2.0",
44
"mcpName": "io.github.Automations-Project/airtable-user-mcp",
55
"description": "Community MCP server for Airtable. Schema inspection, field CRUD, view configuration, formula validation, and extension management. Not affiliated with Airtable Inc.",
66
"main": "src/index.js",

packages/mcp-server/src/client.js

Lines changed: 279 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,139 @@ function ensureFilterIds(filterSet) {
3333
});
3434
}
3535

36+
/**
37+
* Map user-friendly operator names to Airtable internal API operators.
38+
* Verified against captured UI traffic (2026-04-17): for text and singleSelect
39+
* fields, the UI sends "=" / "!=" — not "is" / "isNot" / "isAnyOf".
40+
*
41+
* Leaves nested groups and already-correct operators untouched.
42+
*/
43+
function normalizeFilterOperator(op) {
44+
if (op === 'is') return '=';
45+
if (op === 'isNot') return '!=';
46+
return op;
47+
}
48+
49+
function normalizeFilterSet(filterSet) {
50+
if (!Array.isArray(filterSet)) return filterSet;
51+
return filterSet.map(filter => {
52+
if (filter.type === 'nested' && Array.isArray(filter.filterSet)) {
53+
return { ...filter, filterSet: normalizeFilterSet(filter.filterSet) };
54+
}
55+
if (!filter.operator) return filter;
56+
const next = { ...filter, operator: normalizeFilterOperator(filter.operator) };
57+
// isAnyOf with a single scalar or single-element array collapses to "=" with scalar value
58+
if (filter.operator === 'isAnyOf' && filter.value !== undefined) {
59+
if (Array.isArray(filter.value) && filter.value.length === 1) {
60+
next.operator = '=';
61+
next.value = filter.value[0];
62+
} else if (!Array.isArray(filter.value)) {
63+
next.operator = '=';
64+
}
65+
}
66+
return next;
67+
});
68+
}
69+
70+
/**
71+
* Normalize user-friendly field types to Airtable's internal API shape.
72+
* Verified against captured UI traffic (2026-04-17):
73+
* URL → type: "text", typeOptions: { validatorName: "url" }
74+
* Email → type: "text", typeOptions: { validatorName: "email" }
75+
* Phone → type: "text", typeOptions: { validatorName: "phoneNumber" }
76+
* DateTime → type: "date", typeOptions: { isDateTime: true, dateFormat, timeFormat, timeZone, shouldDisplayTimeZone }
77+
* Note: dateFormat/timeFormat are FLAT STRINGS ("Local", "24hour"), not { name: "..." } as in the public REST API.
78+
*
79+
* Users who pass the canonical internal type ("text" / "date") get their typeOptions passed through.
80+
*/
81+
function normalizeFieldType(type, typeOptions = {}) {
82+
const opts = typeOptions || {};
83+
84+
if (type === 'url' || type === 'URL') {
85+
return { type: 'text', typeOptions: { validatorName: 'url', ...opts } };
86+
}
87+
if (type === 'email' || type === 'Email') {
88+
return { type: 'text', typeOptions: { validatorName: 'email', ...opts } };
89+
}
90+
if (type === 'phone' || type === 'phoneNumber' || type === 'Phone') {
91+
return { type: 'text', typeOptions: { validatorName: 'phoneNumber', ...opts } };
92+
}
93+
if (type === 'dateTime' || type === 'datetime' || type === 'DateTime') {
94+
return {
95+
type: 'date',
96+
typeOptions: {
97+
isDateTime: true,
98+
dateFormat: flattenFormatOption(opts.dateFormat, 'Local'),
99+
timeFormat: flattenFormatOption(opts.timeFormat, '24hour'),
100+
timeZone: opts.timeZone || 'UTC',
101+
shouldDisplayTimeZone: opts.shouldDisplayTimeZone !== undefined ? opts.shouldDisplayTimeZone : true,
102+
},
103+
};
104+
}
105+
// date type — if isDateTime is true, normalize format options the same way
106+
if (type === 'date' && opts.isDateTime) {
107+
return {
108+
type: 'date',
109+
typeOptions: {
110+
...opts,
111+
dateFormat: flattenFormatOption(opts.dateFormat, 'Local'),
112+
timeFormat: flattenFormatOption(opts.timeFormat, '24hour'),
113+
},
114+
};
115+
}
116+
return { type, typeOptions: opts };
117+
}
118+
119+
/** Accept either "Local"/"iso"/"friendly" (flat string, what internal API expects)
120+
* or the public REST shape { name: "iso" } and flatten. */
121+
function flattenFormatOption(value, fallback) {
122+
if (value == null) return fallback;
123+
if (typeof value === 'string') return value;
124+
if (typeof value === 'object' && typeof value.name === 'string') return value.name;
125+
return fallback;
126+
}
127+
128+
/**
129+
* Summarize the dependency graph returned by a failed delete_field check.
130+
* Airtable returns `applicationDependencyGraphEdgesBySourceObjectId` which can
131+
* be hundreds of KB. Compress it to a usable list per consumer type.
132+
*/
133+
function summarizeFieldDependencies(details, fieldId) {
134+
const edges = details?.applicationDependencyGraphEdgesBySourceObjectId?.[fieldId];
135+
if (!Array.isArray(edges)) {
136+
// Unknown shape — return a compact stub so callers can still act
137+
return { edgeCount: 0, viewGroupings: [], viewSorts: [], viewFilters: [], fields: [], other: [] };
138+
}
139+
const viewGroupings = [];
140+
const viewSorts = [];
141+
const viewFilters = [];
142+
const fields = [];
143+
const other = [];
144+
for (const edge of edges) {
145+
const kind = edge?.dependencyType || edge?.edgeType || edge?.type || 'unknown';
146+
const target = edge?.targetObjectId || edge?.objectId || null;
147+
const entry = { targetObjectId: target, dependencyType: kind };
148+
if (target && target.startsWith('viw')) {
149+
if (/group/i.test(kind)) viewGroupings.push(entry);
150+
else if (/sort/i.test(kind)) viewSorts.push(entry);
151+
else if (/filter/i.test(kind)) viewFilters.push(entry);
152+
else other.push(entry);
153+
} else if (target && target.startsWith('fld')) {
154+
fields.push(entry);
155+
} else {
156+
other.push(entry);
157+
}
158+
}
159+
return {
160+
edgeCount: edges.length,
161+
viewGroupings,
162+
viewSorts,
163+
viewFilters,
164+
fields,
165+
other,
166+
};
167+
}
168+
36169
/**
37170
* Airtable Internal API Client.
38171
*
@@ -155,12 +288,14 @@ export class AirtableClient {
155288
const columnId = 'fld' + this._genRandomId();
156289
const url = `https://airtable.com/v0.3/column/${columnId}/create`;
157290

291+
const normalized = normalizeFieldType(fieldConfig.type, fieldConfig.typeOptions);
292+
158293
const payload = {
159294
tableId,
160295
name: fieldConfig.name,
161296
config: {
162-
type: fieldConfig.type,
163-
typeOptions: fieldConfig.typeOptions || {},
297+
type: normalized.type,
298+
typeOptions: normalized.typeOptions,
164299
},
165300
};
166301

@@ -193,10 +328,12 @@ export class AirtableClient {
193328

194329
const url = `https://airtable.com/v0.3/column/${columnId}/updateConfig`;
195330

331+
const normalized = normalizeFieldType(config.type, config.typeOptions);
332+
196333
// Flat payload — matches real Airtable requests
197334
const payload = {
198-
type: config.type,
199-
typeOptions: config.typeOptions || {},
335+
type: normalized.type,
336+
typeOptions: normalized.typeOptions,
200337
};
201338

202339
const res = await this.auth.postForm(url, this._mutationParams(payload, appId), appId);
@@ -278,13 +415,17 @@ export class AirtableClient {
278415
}
279416

280417
if (!force) {
281-
// Return dependency info so caller can decide
418+
// Return a summarized dep list so callers can read the response without
419+
// wading through hundreds of KB of graph JSON. Full graph still available
420+
// via the MCP tool's debug mode.
421+
const summary = summarizeFieldDependencies(checkBody.error.details, fieldId);
282422
return {
283423
deleted: false,
284424
fieldId,
285425
name: expectedName,
286426
hasDependencies: true,
287-
dependencies: checkBody.error.details,
427+
dependencies: summary,
428+
rawDependencyGraph: checkBody.error.details,
288429
message: 'Field has downstream dependencies. Set force=true to delete anyway.',
289430
};
290431
}
@@ -340,6 +481,127 @@ export class AirtableClient {
340481
};
341482
}
342483

484+
// ─── Table Mutations ──────────────────────────────────────────
485+
486+
/**
487+
* Create a new table in a base.
488+
* Verified (2026-04-17): POST /v0.3/table/{newTblId}/create
489+
* Payload: { applicationId, name }
490+
*/
491+
async createTable(appId, name) {
492+
const tableId = 'tbl' + this._genRandomId();
493+
const url = `https://airtable.com/v0.3/table/${tableId}/create`;
494+
495+
const payload = { applicationId: appId, name };
496+
497+
const res = await this.auth.postForm(url, this._mutationParams(payload, appId), appId);
498+
499+
if (!res.ok) {
500+
const errBody = await res.text().catch(() => '');
501+
throw new Error(`createTable failed (${res.status}): ${errBody}`);
502+
}
503+
504+
this.cache.invalidate(appId);
505+
const data = await res.json().catch(() => ({}));
506+
return { tableId, ...data };
507+
}
508+
509+
/**
510+
* Rename a table.
511+
* Verified (2026-04-17): POST /v0.3/table/{tblId}/updateName
512+
* Payload: { name }
513+
*/
514+
async renameTable(appId, tableId, newName) {
515+
const { name } = await this._resolveTableById(appId, tableId);
516+
if (name === newName) {
517+
return { message: 'Table already has this name', tableId, name: newName };
518+
}
519+
520+
const url = `https://airtable.com/v0.3/table/${tableId}/updateName`;
521+
const res = await this.auth.postForm(url, this._mutationParams({ name: newName }, appId), appId);
522+
523+
if (!res.ok) {
524+
const errBody = await res.text().catch(() => '');
525+
throw new Error(`renameTable failed (${res.status}): ${errBody}`);
526+
}
527+
528+
this.cache.invalidate(appId);
529+
return res.json();
530+
}
531+
532+
/**
533+
* Delete a table.
534+
* Verified (2026-04-17): POST /v0.3/table/{tblId}/destroy
535+
* Payload: {}
536+
*
537+
* Airtable rejects deleting the only remaining table in a base.
538+
* Requires expectedName as a safety guard, matching delete_field's pattern.
539+
*/
540+
async deleteTable(appId, tableId, expectedName) {
541+
const { name } = await this._resolveTableById(appId, tableId);
542+
543+
if (name !== expectedName) {
544+
throw new Error(
545+
`Safety check failed: table ${tableId} is named "${name}" but expectedName was "${expectedName}". ` +
546+
`Refusing to delete to prevent accidental data loss.`
547+
);
548+
}
549+
550+
const url = `https://airtable.com/v0.3/table/${tableId}/destroy`;
551+
const res = await this.auth.postForm(url, this._mutationParams({}, appId), appId);
552+
553+
if (!res.ok) {
554+
const errBody = await res.text().catch(() => '');
555+
throw new Error(`deleteTable failed (${res.status}): ${errBody}`);
556+
}
557+
558+
this.cache.invalidate(appId);
559+
const data = await res.json().catch(() => ({}));
560+
return { deleted: true, tableId, name: expectedName, ...data };
561+
}
562+
563+
/** Internal: resolve a table by ID only (no name matching). */
564+
async _resolveTableById(appId, tableId) {
565+
const data = await this.getApplicationData(appId);
566+
const tables = data?.data?.tableSchemas || data?.data?.tables || [];
567+
const table = tables.find(t => t.id === tableId);
568+
if (!table) {
569+
throw new Error(`Table "${tableId}" not found in base ${appId}.`);
570+
}
571+
return table;
572+
}
573+
574+
// ─── View Reads ───────────────────────────────────────────────
575+
576+
/**
577+
* Read a view's current configuration (filters, sorts, grouping, visibility)
578+
* from the base schema. Uses the cached getApplicationData call — no separate
579+
* endpoint is required because the schema already carries every view's state.
580+
*/
581+
async getView(appId, viewId) {
582+
const data = await this.getApplicationData(appId);
583+
const tables = data?.data?.tableSchemas || data?.data?.tables || [];
584+
for (const table of tables) {
585+
const views = table.views || [];
586+
const view = views.find(v => v.id === viewId);
587+
if (!view) continue;
588+
return {
589+
id: view.id,
590+
name: view.name,
591+
type: view.type,
592+
tableId: table.id,
593+
filters: view.filters ?? view.filtersById ?? null,
594+
sorts: view.sorts ?? null,
595+
groupLevels: view.groupLevels ?? null,
596+
visibleColumnOrder: view.visibleColumnOrder ?? view.columnOrder ?? null,
597+
rowHeight: view.rowHeight ?? null,
598+
description: view.description ?? null,
599+
raw: view,
600+
};
601+
}
602+
throw new Error(`View "${viewId}" not found in base ${appId}.`);
603+
}
604+
343605
// ─── View Mutations ───────────────────────────────────────────
344606

345607
/**
@@ -474,11 +736,17 @@ export class AirtableClient {
474736
* are auto-generated before sending.
475737
*/
476738
async updateViewFilters(appId, viewId, filters) {
477-
// Airtable requires every filter to carry a unique flt-prefixed id.
478-
// Auto-inject missing IDs so callers don't need to generate them.
479-
const processedFilters = { ...filters };
480-
if (Array.isArray(processedFilters.filterSet)) {
481-
processedFilters.filterSet = ensureFilterIds(processedFilters.filterSet);
739+
// Passing null / empty clears filters.
740+
let processedFilters = filters;
741+
if (filters && Array.isArray(filters.filterSet)) {
742+
// Airtable requires every filter to carry a unique flt-prefixed id.
743+
// Auto-inject missing IDs so callers don't need to generate them.
744+
// Also normalize operator names (is → =, isNot → !=) to match what
745+
// Airtable's internal API accepts for text / singleSelect fields.
746+
processedFilters = {
747+
...filters,
748+
filterSet: ensureFilterIds(normalizeFilterSet(filters.filterSet)),
749+
};
482750
}
483751

484752
const url = `https://airtable.com/v0.3/view/${viewId}/updateFilters`;

0 commit comments

Comments
 (0)