Skip to content

Commit 5a8cfef

Browse files
ARHAEEMclaude
andcommitted
feat(mcp): add AirtableClient.deleteFields bulk method with per-field error handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4ee5811 commit 5a8cfef

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

packages/mcp-server/src/client.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,33 @@ export class AirtableClient {
691691
return { deleted: true, fieldId, name: expectedName, forced: true, ...data };
692692
}
693693

694+
/**
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.
698+
*
699+
* @param {string} appId
700+
* @param {{fieldId: string, expectedName: string}[]} fields
701+
* @param {{ force?: boolean, onProgress?: Function }} options
702+
*/
703+
async deleteFields(appId, fields, { force = false, onProgress } = {}) {
704+
const succeeded = [];
705+
const failed = [];
706+
707+
for (let i = 0; i < fields.length; i++) {
708+
const { fieldId, expectedName } = fields[i];
709+
try {
710+
const result = await this.deleteField(appId, fieldId, expectedName, { force });
711+
succeeded.push({ fieldId, name: expectedName, deleted: result.deleted, forced: result.forced ?? false });
712+
} catch (error) {
713+
failed.push({ fieldId, name: expectedName, error: error.message });
714+
}
715+
onProgress?.({ index: i, total: fields.length, succeeded: succeeded.length, failed: failed.length });
716+
}
717+
718+
return { succeeded, failed };
719+
}
720+
694721
// ─── Formula Validation ───────────────────────────────────────
695722

696723
/**

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,99 @@ describe('tool-call semaphore helpers', () => {
5555
assert.ok(src.includes('_pendingToolQueue'), '_pendingToolQueue must be defined');
5656
});
5757
});
58+
59+
import { AirtableClient } from '../src/client.js';
60+
61+
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+
};
79+
const auth = createMockAuth({
80+
get() {
81+
return { ok: true, status: 200, json: async () => SCHEMA, text: async () => '{}' };
82+
},
83+
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' };
87+
}
88+
return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
89+
},
90+
});
91+
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');
102+
});
103+
104+
it('calls onProgress once per field', async () => {
105+
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+
};
114+
},
115+
});
116+
const client = new AirtableClient(auth);
117+
const progressLog = [];
118+
await client.deleteFields('appTEST', [{ fieldId: 'fld001', expectedName: 'A' }], {
119+
onProgress: (info) => progressLog.push(info),
120+
});
121+
assert.equal(progressLog.length, 1);
122+
assert.equal(progressLog[0].index, 0);
123+
assert.equal(progressLog[0].total, 1);
124+
});
125+
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+
};
136+
const auth = createMockAuth({
137+
get() {
138+
return { ok: true, status: 200, json: async () => SCHEMA2, text: async () => '{}' };
139+
},
140+
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 () => '{}' };
143+
},
144+
});
145+
const client = new AirtableClient(auth);
146+
const result = await client.deleteFields('appTEST', [
147+
{ fieldId: 'fld001', expectedName: 'A' },
148+
{ fieldId: 'fld002', expectedName: 'B' },
149+
], { force: true });
150+
assert.equal(result.succeeded.length, 1);
151+
assert.equal(result.failed.length, 1);
152+
});
153+
});

0 commit comments

Comments
 (0)