Skip to content

Commit fdfc464

Browse files
ARHAEEMclaude
andcommitted
fix(mcp): issues #9 and #10 — list_tables empty array bug + queryRecords wrong response path
Issue #9 — list_tables returned [] for bases where the getApplicationScaffoldingData endpoint returns tableById + visibleTableOrder alongside an empty tables:[] stub. Empty arrays are truthy in JS, so the || chain short-circuited at tables:[] before reaching Object.values(tableById). Fix: extracted parsing into AirtableClient.parseScaffoldingTables() which guards each candidate with .length > 0, handles visibleTableOrder in addition to tableOrder, and uses Object.values(tableById) as the final fallback. list_tables handler now delegates to this method. 5 regression tests added. Issue #10 — query_records returned {count:0, rows:[]} for views with real records. Two bugs: 1. Wrong response field: code read queryResults[queryId].rows but the readQueries endpoint returns querySlices[0].rowIds + tableDataById[tableId].partialRowById (confirmed from capture 2026-05-21T14-16-09-125Z.json). 2. Silent failure: res.json().catch(()=>({})) swallowed non-JSON (msgpack) parse errors, masking the problem entirely. Fix: rewrote response parsing to use the correct querySlices + tableDataById path. res.json() replaced with res.text() + explicit JSON.parse that throws a descriptive error on msgpack/binary responses instead of returning empty. failureReasonByQueryId now surfaces query-level errors. Debug mode now exposes the raw API response instead of the already-processed result. 4 regression tests added. 249 tests pass (245 before + 9 new). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6b1bc6a commit fdfc464

3 files changed

Lines changed: 209 additions & 18 deletions

File tree

packages/mcp-server/src/client.js

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,20 @@ export class AirtableClient {
494494
return data;
495495
}
496496

497+
// Normalizes the data block from getScaffoldingData into a flat table array.
498+
// Guards every candidate with a length check — empty arrays are truthy and
499+
// would short-circuit the chain before reaching tableById (the scaffolding
500+
// endpoint returns tableById + visibleTableOrder, not tableSchemas/tables).
501+
parseScaffoldingTables(d) {
502+
return (d?.tableSchemas?.length > 0 && d.tableSchemas) ||
503+
(d?.tables?.length > 0 && d.tables) ||
504+
(d?.visibleTableOrder || d?.tableOrder)?.map(id => {
505+
const t = d?.tableById?.[id] || d?.tableDatas?.[id] || {};
506+
return { id, name: t.name || id };
507+
}) ||
508+
Object.values(d?.tableById || {});
509+
}
510+
497511
async getUserProperties() {
498512
const res = await this.auth.get('https://airtable.com/v0.3/getUserProperties');
499513
if (!res.ok) {
@@ -2282,11 +2296,37 @@ export class AirtableClient {
22822296
throw new Error(`readQueries failed (${res.status}): ${errText}`);
22832297
}
22842298

2285-
const data = await res.json().catch(() => ({}));
2286-
const queryResult = data?.data?.queryResults?.[queryId] || {};
2287-
const rows = queryResult.rows || [];
2299+
// Fetch as text first so a non-JSON (msgpack) response surfaces as an error
2300+
// instead of silently returning empty results.
2301+
const text = await res.text().catch(() => '');
2302+
let data;
2303+
try {
2304+
data = JSON.parse(text);
2305+
} catch (e) {
2306+
throw new Error(
2307+
`readQueries returned non-JSON (${text.length} bytes, first byte 0x${text.charCodeAt(0).toString(16)}). ` +
2308+
`The server may not be honoring allowMsgpackOfResult:false. ` +
2309+
`Try re-authenticating or filing a bug.`,
2310+
);
2311+
}
22882312

2289-
const mapped = rows.map(r => ({ id: r.id, fields: r.cellValuesByColumnId || r.cellValues || {} }));
2313+
// Surface query-level failures (e.g. bad viewId, permissions).
2314+
const failure = data?.data?.failureReasonByQueryId?.[queryId];
2315+
if (failure) throw new Error(`readQueries query failed: ${JSON.stringify(failure)}`);
2316+
2317+
// Response structure (captured 2026-05-21):
2318+
// data.querySlices[0].rowIds — ordered record IDs for this query
2319+
// data.tableDataById[tableId]
2320+
// .partialRowById[rowId]
2321+
// .cellValuesByColumnId — resolved cell values per field
2322+
const slice = data?.data?.querySlices?.[0] || {};
2323+
const rowIds = slice.rowIds || [];
2324+
const tableData = data?.data?.tableDataById?.[tableId] || {};
2325+
2326+
const mapped = rowIds.map(rowId => {
2327+
const rowData = tableData.partialRowById?.[rowId] || {};
2328+
return { id: rowId, fields: rowData.cellValuesByColumnId || {} };
2329+
});
22902330

22912331
// Client-side substring search across all resolved field values (including lookup fields).
22922332
// This avoids the FIND()/SEARCH() formula limitation of the REST API that doesn't reach
@@ -2305,12 +2345,15 @@ export class AirtableClient {
23052345
: mapped;
23062346

23072347
return {
2308-
tableId,
2309-
viewId,
2310-
limit: clampedLimit,
2311-
search: search || null,
2312-
count: filtered.length,
2313-
rows: filtered,
2348+
summary: {
2349+
tableId,
2350+
viewId,
2351+
limit: clampedLimit,
2352+
search: search || null,
2353+
count: filtered.length,
2354+
rows: filtered,
2355+
},
2356+
raw: data,
23142357
};
23152358
}
23162359

packages/mcp-server/src/index.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,12 +1570,7 @@ const handlers = {
15701570

15711571
async list_tables({ appId, debug }) {
15721572
const raw = await client.getScaffoldingData(appId);
1573-
const tables = raw?.data?.tableSchemas || raw?.data?.tables ||
1574-
raw?.data?.tableOrder?.map(id => {
1575-
const t = raw?.data?.tableDatas?.[id] || raw?.data?.tableById?.[id] || {};
1576-
return { id, name: t.name || id };
1577-
}) ||
1578-
Object.values(raw?.data?.tableById || {});
1573+
const tables = client.parseScaffoldingTables(raw?.data);
15791574
const summary = tables.map(t => ({
15801575
id: t.id,
15811576
name: t.name,
@@ -2245,8 +2240,8 @@ const handlers = {
22452240
// ── Record Read ──
22462241

22472242
async query_records({ appId, tableId, viewId, columnIds, limit, search, debug }) {
2248-
const result = await client.queryRecords(appId, tableId, viewId, { columnIds, limit, search });
2249-
return ok(result, result, debug);
2243+
const { summary, raw } = await client.queryRecords(appId, tableId, viewId, { columnIds, limit, search });
2244+
return ok(summary, raw, debug);
22502245
},
22512246

22522247
// ── Record Write ──

packages/mcp-server/test/test-client.test.js

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,59 @@ describe('AirtableClient', () => {
333333
});
334334
});
335335

336+
// Regression tests for issue #9 — list_tables returned [] when the scaffolding
337+
// endpoint returned tableById + visibleTableOrder (empty arrays are truthy and
338+
// previously short-circuited the fallback chain before reaching tableById).
339+
describe('parseScaffoldingTables (issue #9 regression)', () => {
340+
it('returns tables from tableById when tables:[] is present (empty-array truthy bug)', () => {
341+
const result = client.parseScaffoldingTables({
342+
tables: [], // empty array — truthy, was short-circuiting the fallback chain
343+
tableById: {
344+
tblAAA: { id: 'tblAAA', name: 'Tasks' },
345+
tblBBB: { id: 'tblBBB', name: 'Projects' },
346+
},
347+
visibleTableOrder: ['tblAAA', 'tblBBB'],
348+
});
349+
assert.equal(result.length, 2);
350+
assert.deepEqual(result.map(t => t.id), ['tblAAA', 'tblBBB']);
351+
});
352+
353+
it('respects visibleTableOrder for correct sort', () => {
354+
const result = client.parseScaffoldingTables({
355+
tableById: {
356+
tblAAA: { id: 'tblAAA', name: 'Tasks' },
357+
tblBBB: { id: 'tblBBB', name: 'Projects' },
358+
},
359+
visibleTableOrder: ['tblBBB', 'tblAAA'],
360+
});
361+
assert.equal(result[0].name, 'Projects');
362+
assert.equal(result[1].name, 'Tasks');
363+
});
364+
365+
it('prefers non-empty tableSchemas over tableById', () => {
366+
const result = client.parseScaffoldingTables({
367+
tableSchemas: [{ id: 'tblXXX', name: 'Schema Table' }],
368+
tableById: { tblYYY: { id: 'tblYYY', name: 'Dict Table' } },
369+
});
370+
assert.equal(result.length, 1);
371+
assert.equal(result[0].id, 'tblXXX');
372+
});
373+
374+
it('skips empty tableSchemas and uses tableById', () => {
375+
const result = client.parseScaffoldingTables({
376+
tableSchemas: [],
377+
tableById: { tblAAA: { id: 'tblAAA', name: 'Tasks' } },
378+
});
379+
assert.equal(result.length, 1);
380+
assert.equal(result[0].name, 'Tasks');
381+
});
382+
383+
it('returns [] for empty data', () => {
384+
assert.deepEqual(client.parseScaffoldingTables({}), []);
385+
assert.deepEqual(client.parseScaffoldingTables(null), []);
386+
});
387+
});
388+
336389
describe('validateFormula', () => {
337390
it('returns valid result for good formula', async () => {
338391
mockAuth.postForm = () => ({
@@ -827,6 +880,106 @@ describe('AirtableClient', () => {
827880
});
828881
});
829882

883+
// Regression tests for issue #10 — queryRecords returned {count:0, rows:[]} because:
884+
// 1. Wrong response field: code used queryResults[queryId] but API returns querySlices[0]
885+
// 2. Cell values at tableDataById[tableId].partialRowById[rowId].cellValuesByColumnId
886+
// 3. Silent failure: res.json().catch(()=>({})) hid msgpack parse errors
887+
describe('queryRecords (issue #10 regression)', () => {
888+
function makeReadQueriesAuth(querySlicesResponse) {
889+
return createMockAuth({
890+
get: (url) => {
891+
if (url.includes('/read') || url.includes('getApplicationScaffoldingData')) {
892+
return { ok: true, status: 200, json: async () => MOCK_SCHEMA, text: async () => JSON.stringify(MOCK_SCHEMA) };
893+
}
894+
return { ok: true, status: 200, json: async () => ({}), text: async () => '{}' };
895+
},
896+
postForm: () => ({
897+
ok: true,
898+
status: 200,
899+
json: async () => querySlicesResponse,
900+
text: async () => JSON.stringify(querySlicesResponse),
901+
}),
902+
});
903+
}
904+
905+
it('parses querySlices+tableDataById response correctly', async () => {
906+
const mockApiResponse = {
907+
data: {
908+
querySlices: [{
909+
tableId: 'tblAAA',
910+
rowIds: ['recAAA', 'recBBB'],
911+
columnIds: ['fld111', 'fld222'],
912+
}],
913+
tableDataById: {
914+
tblAAA: {
915+
partialRowById: {
916+
recAAA: { createdTime: '2026-01-01T00:00:00.000Z', cellValuesByColumnId: { fld111: 'Task 1', fld222: 'Done' } },
917+
recBBB: { createdTime: '2026-01-02T00:00:00.000Z', cellValuesByColumnId: { fld111: 'Task 2', fld222: 'In Progress' } },
918+
},
919+
},
920+
},
921+
failureReasonByQueryId: {},
922+
},
923+
};
924+
const auth = makeReadQueriesAuth(mockApiResponse);
925+
const client = new AirtableClient(auth);
926+
const { summary } = await client.queryRecords('appXXX', 'tblAAA', 'viwBBB', { limit: 10 });
927+
assert.equal(summary.count, 2);
928+
assert.equal(summary.rows[0].id, 'recAAA');
929+
assert.equal(summary.rows[0].fields.fld111, 'Task 1');
930+
assert.equal(summary.rows[1].fields.fld222, 'In Progress');
931+
});
932+
933+
it('returns 0 rows when querySlices is absent (not an error)', async () => {
934+
const auth = makeReadQueriesAuth({ data: { querySlices: [], tableDataById: {}, failureReasonByQueryId: {} } });
935+
const client = new AirtableClient(auth);
936+
const { summary } = await client.queryRecords('appXXX', 'tblAAA', 'viwBBB');
937+
assert.equal(summary.count, 0);
938+
});
939+
940+
it('throws on non-JSON response instead of silently returning empty', async () => {
941+
const auth = createMockAuth({
942+
get: (url) => ({ ok: true, status: 200, json: async () => MOCK_SCHEMA, text: async () => JSON.stringify(MOCK_SCHEMA) }),
943+
postForm: () => ({
944+
ok: true,
945+
status: 200,
946+
text: async () => '\xfd\x72\x40binary-not-json', // msgpack-like binary
947+
}),
948+
});
949+
const client = new AirtableClient(auth);
950+
await assert.rejects(
951+
() => client.queryRecords('appXXX', 'tblAAA', 'viwBBB'),
952+
{ message: /non-JSON/ },
953+
);
954+
});
955+
956+
it('throws on query-level failure', async () => {
957+
const auth = makeReadQueriesAuth({
958+
data: {
959+
querySlices: [],
960+
tableDataById: {},
961+
failureReasonByQueryId: { 'qryANYID': { type: 'PERMISSION_DENIED' } },
962+
},
963+
});
964+
// We need to override postForm to capture the dynamic queryId and inject it into failure map.
965+
// Instead, test with a static failure that doesn't need the exact queryId:
966+
const staticAuth = createMockAuth({
967+
get: (url) => ({ ok: true, status: 200, json: async () => MOCK_SCHEMA, text: async () => JSON.stringify(MOCK_SCHEMA) }),
968+
postForm: (url, params) => {
969+
const payload = JSON.parse(params.stringifiedObjectParams);
970+
const qid = payload.queries[0].id;
971+
const resp = { data: { querySlices: [], tableDataById: {}, failureReasonByQueryId: { [qid]: { type: 'PERMISSION_DENIED' } } } };
972+
return { ok: true, status: 200, text: async () => JSON.stringify(resp) };
973+
},
974+
});
975+
const client = new AirtableClient(staticAuth);
976+
await assert.rejects(
977+
() => client.queryRecords('appXXX', 'tblAAA', 'viwBBB'),
978+
{ message: /PERMISSION_DENIED/ },
979+
);
980+
});
981+
});
982+
830983
describe('Airtable ID validation (defense-in-depth)', () => {
831984
// These tests guard against URL-injection regressions. Every client
832985
// method that interpolates an ID into a URL must validate it up-front.

0 commit comments

Comments
 (0)