@@ -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 ( / g r o u p / i. test ( kind ) ) viewGroupings . push ( entry ) ;
150+ else if ( / s o r t / i. test ( kind ) ) viewSorts . push ( entry ) ;
151+ else if ( / f i l t e r / 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