Skip to content

Commit d1737a7

Browse files
ARHAEEMclaude
andcommitted
feat(mcp): use native destroyMultipleColumns batch endpoint in deleteFields
Replaces the per-field loop (N×3 browser evals) with 1-2 batch API calls per table group via POST /v0.3/table/{tableId}/destroyMultipleColumns. Safety checks (expectedName) still validated before any destructive call. Fields from different tables are batched independently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dbd84bc commit d1737a7

2 files changed

Lines changed: 162 additions & 104 deletions

File tree

packages/mcp-server/src/client.js

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -692,9 +692,9 @@ export class AirtableClient {
692692
}
693693

694694
/**
695-
* Delete multiple fields sequentially. Returns { succeeded, failed }.
696-
* Each failed entry includes the fieldId and error message but does NOT
697-
* abort the rest of the batch — all fields are attempted.
695+
* Delete multiple fields using the native batch endpoint (destroyMultipleColumns).
696+
* Fields are grouped by tableId and each table's fields are deleted in 1-2 API
697+
* calls instead of N×3. Returns { succeeded, failed }.
698698
*
699699
* @param {string} appId
700700
* @param {{fieldId: string, expectedName: string}[]} fields
@@ -704,21 +704,82 @@ export class AirtableClient {
704704
const succeeded = [];
705705
const failed = [];
706706

707-
for (let i = 0; i < fields.length; i++) {
708-
const { fieldId, expectedName } = fields[i];
707+
// Step 1: Validate expectedName safety checks and group by tableId.
708+
// getApplicationData is cached, so N resolveField calls cost one network round-trip.
709+
const byTable = new Map(); // tableId → [{ fieldId, expectedName }]
710+
711+
for (const { fieldId, expectedName } of fields) {
709712
try {
710-
const result = await this.deleteField(appId, fieldId, expectedName, { force });
711-
if (!result.deleted) {
712-
failed.push({ fieldId, name: expectedName, error: result.message ?? 'Field was not deleted' });
713+
const { field, table } = await this.resolveField(appId, fieldId);
714+
if (field.name !== expectedName) {
715+
failed.push({
716+
fieldId,
717+
name: expectedName,
718+
error: `Safety check failed: field is named "${field.name}" but expectedName was "${expectedName}"`,
719+
});
713720
} else {
714-
succeeded.push({ fieldId, name: expectedName, deleted: true, forced: result.forced ?? false });
721+
if (!byTable.has(table.id)) byTable.set(table.id, []);
722+
byTable.get(table.id).push({ fieldId, expectedName });
715723
}
716-
} catch (error) {
717-
failed.push({ fieldId, name: expectedName, error: error.message });
724+
} catch (err) {
725+
failed.push({ fieldId, name: expectedName, error: err.message });
718726
}
719-
onProgress?.({ index: i, total: fields.length, succeeded: succeeded.length, failed: failed.length });
720727
}
721728

729+
// Step 2: Batch-delete each table's fields via destroyMultipleColumns (1-2 calls per table).
730+
for (const [tableId, tableFields] of byTable) {
731+
const columnIds = tableFields.map(f => f.fieldId);
732+
733+
const checkRes = await this.auth.postForm(
734+
`https://airtable.com/v0.3/table/${tableId}/destroyMultipleColumns`,
735+
this._mutationParams({ columnIds, checkSchemaDependencies: true }, appId),
736+
appId,
737+
);
738+
739+
if (checkRes.ok) {
740+
this.cache.invalidate(appId);
741+
for (const f of tableFields) {
742+
succeeded.push({ fieldId: f.fieldId, name: f.expectedName, deleted: true, forced: false });
743+
}
744+
continue;
745+
}
746+
747+
const checkBody = await checkRes.json().catch(() => null);
748+
const isDependencyError = checkBody?.error?.type === 'SCHEMA_DEPENDENCIES_VALIDATION_FAILED';
749+
750+
if (!isDependencyError || !force) {
751+
const errMsg = isDependencyError
752+
? 'Fields have schema dependencies and force=false'
753+
: `destroyMultipleColumns failed (${checkRes.status})`;
754+
for (const f of tableFields) {
755+
failed.push({ fieldId: f.fieldId, name: f.expectedName, error: errMsg });
756+
}
757+
continue;
758+
}
759+
760+
// Deps exist and force=true: second call without checkSchemaDependencies
761+
const forceRes = await this.auth.postForm(
762+
`https://airtable.com/v0.3/table/${tableId}/destroyMultipleColumns`,
763+
this._mutationParams({ columnIds }, appId),
764+
appId,
765+
);
766+
767+
if (!forceRes.ok) {
768+
const errText = await forceRes.text().catch(() => '');
769+
for (const f of tableFields) {
770+
failed.push({ fieldId: f.fieldId, name: f.expectedName, error: `destroyMultipleColumns force-delete failed (${forceRes.status}): ${errText}` });
771+
}
772+
} else {
773+
this.cache.invalidate(appId);
774+
for (const f of tableFields) {
775+
succeeded.push({ fieldId: f.fieldId, name: f.expectedName, deleted: true, forced: true });
776+
}
777+
}
778+
}
779+
780+
if (fields.length > 0) {
781+
onProgress?.({ index: fields.length - 1, total: fields.length, succeeded: succeeded.length, failed: failed.length });
782+
}
722783
return { succeeded, failed };
723784
}
724785

packages/mcp-server/test/test-bulk-delete.test.js

Lines changed: 89 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -59,129 +59,126 @@ describe('tool-call semaphore helpers', () => {
5959
import { AirtableClient } from '../src/client.js';
6060

6161
describe('AirtableClient.deleteFields', () => {
62-
it('processes all fields and reports succeeded/failed counts', async () => {
63-
// Schema contains all three fields so resolveField succeeds for each.
64-
// The cache is invalidated after each successful delete, causing a re-fetch
65-
// that always returns the full schema. postForm fails only for fldBAD.
66-
const SCHEMA = {
67-
data: {
68-
tableSchemas: [{
69-
id: 'tblAAA',
70-
columns: [
71-
{ id: 'fld001', name: 'Field A', type: 'text', typeOptions: {} },
72-
{ id: 'fldBAD', name: 'Bad Field', type: 'text', typeOptions: {} },
73-
{ id: 'fld003', name: 'Field C', type: 'text', typeOptions: {} },
74-
],
75-
views: [],
76-
}],
77-
},
78-
};
62+
it('batch-deletes all fields with no dependencies in one API call', async () => {
63+
const SCHEMA = { data: { tableSchemas: [{ id: 'tblAAA', columns: [
64+
{ id: 'fld001', name: 'Field A', type: 'text', typeOptions: {} },
65+
{ id: 'fld002', name: 'Field B', type: 'text', typeOptions: {} },
66+
], views: [] }] } };
7967
const auth = createMockAuth({
80-
get() {
81-
return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' };
68+
get() { return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' }; },
69+
postForm(url) {
70+
if (url.includes('destroyMultipleColumns')) {
71+
return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
72+
}
73+
return { ok: false, status: 500, json: async () => ({}), text: async () => '' };
8274
},
75+
});
76+
const client = new AirtableClient(auth);
77+
const result = await client.deleteFields('appTEST', [
78+
{ fieldId: 'fld001', expectedName: 'Field A' },
79+
{ fieldId: 'fld002', expectedName: 'Field B' },
80+
]);
81+
assert.equal(result.succeeded.length, 2);
82+
assert.equal(result.failed.length, 0);
83+
const batchCalls = auth.calls.filter(c => c.url.includes('destroyMultipleColumns'));
84+
assert.equal(batchCalls.length, 1, 'should make exactly one batch call when no deps');
85+
});
86+
87+
it('makes two batch calls when force=true and deps exist', async () => {
88+
const SCHEMA = { data: { tableSchemas: [{ id: 'tblAAA', columns: [
89+
{ id: 'fld001', name: 'HasDeps', type: 'text', typeOptions: {} },
90+
], views: [] }] } };
91+
let batchCallCount = 0;
92+
const auth = createMockAuth({
93+
get() { return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' }; },
8394
postForm(url) {
84-
// fldBAD destroy call fails with a non-dependency error → throws in deleteField
85-
if (url.includes('fldBAD')) {
86-
return { ok: false, status: 422, json: async () => ({ error: { type: 'NOT_FOUND' } }), text: async () => 'not found' };
95+
if (!url.includes('destroyMultipleColumns')) return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
96+
batchCallCount++;
97+
if (batchCallCount === 1) {
98+
return { ok: false, status: 422, json: async () => ({ error: { type: 'SCHEMA_DEPENDENCIES_VALIDATION_FAILED' } }), text: async () => '' };
8799
}
88100
return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
89101
},
90102
});
91103
const client = new AirtableClient(auth);
92-
const fields = [
93-
{ fieldId: 'fld001', expectedName: 'Field A' },
94-
{ fieldId: 'fldBAD', expectedName: 'Bad Field' },
95-
{ fieldId: 'fld003', expectedName: 'Field C' },
96-
];
97-
const result = await client.deleteFields('appTEST', fields, { force: true });
98-
assert.equal(result.succeeded.length, 2, 'two fields should succeed');
99-
assert.equal(result.failed.length, 1, 'one field should fail');
100-
assert.equal(result.failed[0].fieldId, 'fldBAD');
101-
assert.ok(typeof result.failed[0].error === 'string', 'error must be a string');
104+
const result = await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'HasDeps' }], { force: true });
105+
assert.equal(result.succeeded.length, 1);
106+
assert.equal(result.failed.length, 0);
107+
assert.equal(result.succeeded[0].forced, true);
108+
assert.equal(batchCallCount, 2, 'should make two batch calls for force=true + deps');
102109
});
103110

104-
it('calls onProgress once per field', async () => {
111+
it('routes to failed when deps exist and force=false', async () => {
112+
const SCHEMA = { data: { tableSchemas: [{ id: 'tblAAA', columns: [
113+
{ id: 'fld001', name: 'HasDeps', type: 'text', typeOptions: {} },
114+
], views: [] }] } };
105115
const auth = createMockAuth({
106-
get() {
107-
return {
108-
ok: true, status: 200,
109-
json: async () => ({
110-
data: { tableSchemas: [{ id: 'tbl1', columns: [{ id: 'fld001', name: 'A', type: 'text', typeOptions: {} }], views: [] }] },
111-
}),
112-
text: async () => '{}',
113-
};
116+
get() { return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' }; },
117+
postForm() {
118+
return { ok: false, status: 422, json: async () => ({ error: { type: 'SCHEMA_DEPENDENCIES_VALIDATION_FAILED' } }), text: async () => '' };
114119
},
115120
});
116121
const client = new AirtableClient(auth);
117-
const progressLog = [];
118-
await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'A' }], {
119-
onProgress: (info) => progressLog.push(info),
122+
const result = await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'HasDeps' }], { force: false });
123+
assert.equal(result.succeeded.length, 0);
124+
assert.equal(result.failed.length, 1);
125+
assert.ok(result.failed[0].error.includes('dependencies'), `expected dep error, got: "${result.failed[0].error}"`);
126+
const batchCalls = auth.calls.filter(c => c.url.includes('destroyMultipleColumns'));
127+
assert.equal(batchCalls.length, 1, 'should stop after first call when force=false');
128+
});
129+
130+
it('routes to failed when expectedName does not match', async () => {
131+
const SCHEMA = { data: { tableSchemas: [{ id: 'tblAAA', columns: [
132+
{ id: 'fld001', name: 'Actual Name', type: 'text', typeOptions: {} },
133+
], views: [] }] } };
134+
const auth = createMockAuth({
135+
get() { return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' }; },
120136
});
121-
assert.equal(progressLog.length, 1);
122-
assert.equal(progressLog[0].index, 0);
123-
assert.equal(progressLog[0].total, 1);
137+
const client = new AirtableClient(auth);
138+
const result = await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'Wrong Name' }]);
139+
assert.equal(result.succeeded.length, 0);
140+
assert.equal(result.failed.length, 1);
141+
assert.ok(result.failed[0].error.includes('Safety check'), `expected safety check error, got: "${result.failed[0].error}"`);
142+
assert.equal(auth.calls.filter(c => c.url.includes('destroyMultipleColumns')).length, 0, 'no batch call if safety check fails');
124143
});
125144

126-
it('continues processing after a per-field failure', async () => {
127-
// Schema contains both fields. fld001 postForm fails (non-dep error → throws),
128-
// fld002 succeeds. Cache is invalidated only on success, but the schema mock
129-
// always returns both fields so both resolveField calls succeed regardless.
130-
const SCHEMA2 = {
131-
data: { tableSchemas: [{ id: 'tbl1', columns: [
132-
{ id: 'fld001', name: 'A', type: 'text', typeOptions: {} },
133-
{ id: 'fld002', name: 'B', type: 'text', typeOptions: {} },
134-
], views: [] }] },
135-
};
145+
it('processes fields from different tables independently', async () => {
146+
const SCHEMA = { data: { tableSchemas: [
147+
{ id: 'tbl1', columns: [{ id: 'fld001', name: 'A', type: 'text', typeOptions: {} }], views: [] },
148+
{ id: 'tbl2', columns: [{ id: 'fld002', name: 'B', type: 'text', typeOptions: {} }], views: [] },
149+
] } };
136150
const auth = createMockAuth({
137-
get() {
138-
return { ok: true, status: 200, json: async () => SCHEMA2, text: async () => '{}' };
139-
},
151+
get() { return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' }; },
140152
postForm(url) {
141-
if (url.includes('fld001')) return { ok: false, status: 422, json: async () => ({ error: { type: 'ERR' } }), text: async () => 'err' };
142-
return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
153+
if (url.includes('tbl1')) return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
154+
return { ok: false, status: 500, json: async () => ({ error: { type: 'INTERNAL' } }), text: async () => 'error' };
143155
},
144156
});
145157
const client = new AirtableClient(auth);
146158
const result = await client.deleteFields('appTEST', [
147159
{ fieldId: 'fld001', expectedName: 'A' },
148160
{ fieldId: 'fld002', expectedName: 'B' },
149-
], { force: true });
161+
]);
150162
assert.equal(result.succeeded.length, 1);
151163
assert.equal(result.failed.length, 1);
164+
assert.equal(result.succeeded[0].fieldId, 'fld001');
165+
assert.equal(result.failed[0].fieldId, 'fld002');
152166
});
153167

154-
it('routes deleted:false (dependency-blocked) to failed[] when force=false', async () => {
155-
const DEPENDENCY_RESPONSE = {
156-
ok: false,
157-
status: 400,
158-
json: async () => ({
159-
error: {
160-
type: 'SCHEMA_DEPENDENCIES_VALIDATION_FAILED',
161-
details: { dependentColumns: [{ id: 'fldDEP', type: 'formula' }] },
162-
},
163-
}),
164-
text: async () => JSON.stringify({ error: { type: 'SCHEMA_DEPENDENCIES_VALIDATION_FAILED' } }),
165-
};
168+
it('calls onProgress once at end with final counts', async () => {
169+
const SCHEMA = { data: { tableSchemas: [{ id: 'tbl1', columns: [
170+
{ id: 'fld001', name: 'A', type: 'text', typeOptions: {} },
171+
], views: [] }] } };
166172
const auth = createMockAuth({
167-
get() {
168-
return {
169-
ok: true, status: 200,
170-
json: async () => ({
171-
data: { tableSchemas: [{ id: 'tbl1', columns: [{ id: 'fld001', name: 'HasDeps', type: 'formula', typeOptions: {} }], views: [] }] },
172-
}),
173-
text: async () => '{}',
174-
};
175-
},
176-
postForm() {
177-
return DEPENDENCY_RESPONSE;
178-
},
173+
get() { return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' }; },
179174
});
180175
const client = new AirtableClient(auth);
181-
const result = await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'HasDeps' }], { force: false });
182-
assert.equal(result.succeeded.length, 0, 'no fields should be in succeeded');
183-
assert.equal(result.failed.length, 1, 'dependency-blocked field should be in failed');
184-
assert.ok(result.failed[0].error.includes('dependencies') || result.failed[0].error.includes('not deleted'),
185-
`error message should mention dependencies or not deleted, got: "${result.failed[0].error}"`);
176+
const progressLog = [];
177+
await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'A' }], {
178+
onProgress: (info) => progressLog.push(info),
179+
});
180+
assert.equal(progressLog.length, 1);
181+
assert.equal(progressLog[0].index, 0);
182+
assert.equal(progressLog[0].total, 1);
186183
});
187184
});

0 commit comments

Comments
 (0)