Skip to content

Commit 86ceff2

Browse files
committed
fix(mcp): add rollup/lookup key translation + omit empty typeOptions to prevent 422 errors
- Add normalizeFieldType() translation for rollup/lookup: public API keys (fieldIdInLinkedTable, recordLinkFieldId) → internal keys (foreignTableRollupColumnId, relationColumnId) - Omit typeOptions entirely when empty in create_field/update_field_config — passing {} causes 422 for text/multilineText/checkbox/rating - Add defensive fallback chain for formula text extraction: typeOptions.formulaText → typeOptions.formula
1 parent 84a8fef commit 86ceff2

4 files changed

Lines changed: 53 additions & 18 deletions

File tree

packages/extension/src/skills/templates/skillTemplates.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,12 +451,12 @@ Non-destructive field operations.
451451
| Field Type | typeOptions |
452452
|------------|-------------|
453453
| formula | \`{ formulaText: "IF({A},1,0)" }\` |
454-
| rollup | \`{ fieldIdInLinkedTable, recordLinkFieldId, resultType, referencedFieldIds }\` |
455-
| lookup | \`{ recordLinkFieldId, fieldIdInLinkedTable }\` |
454+
| rollup | \`{ relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET", formulaText: "SUM(values)" }\` — **formulaText required**. Old keys \`fieldIdInLinkedTable\`/\`recordLinkFieldId\` are auto-translated. |
455+
| lookup | \`{ relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET" }\` — old keys auto-translated. |
456456
| count | \`{ recordLinkFieldId }\` |
457457
| singleSelect | \`{ choices: [{ name: "Option A", color: "blueLight2" }] }\` |
458458
| multipleSelects | \`{ choices: [{ name: "PC" }, { name: "Xbox", color: "greenLight2" }] }\` |
459-
| text, number, checkbox | \`{}\` (no typeOptions needed) |
459+
| text, multilineText, number, checkbox | omit typeOptions entirely (passing \`{}\` causes 422) |
460460
461461
#### Working with Select Choices
462462
Pass choices as an array of \`{ name, color? }\` objects — IDs are auto-generated for new choices.

packages/mcp-server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"files": [
1515
"src/**/*.js",
1616
"src/**/*.mjs",
17+
"src/**/*.json",
1718
"README.md",
1819
"LICENSE"
1920
],

packages/mcp-server/src/client.js

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,22 @@ function normalizeFieldType(type, typeOptions = {}) {
319319
typeOptions: { ...opts, ...(opts.choices ? { choices: normalizeChoices(opts.choices) } : {}) },
320320
};
321321
}
322+
// Rollup and lookup use different key names in the internal API vs the public REST API.
323+
// Translate the public names (fieldIdInLinkedTable, recordLinkFieldId) to the internal
324+
// names (foreignTableRollupColumnId, relationColumnId) so callers can use either vocabulary.
325+
// formulaText is required for rollup; relationColumnId + foreignTableRollupColumnId for lookup.
326+
if (type === 'rollup' || type === 'lookup') {
327+
const translated = { ...opts };
328+
if (opts.recordLinkFieldId !== undefined && opts.relationColumnId === undefined) {
329+
translated.relationColumnId = opts.recordLinkFieldId;
330+
delete translated.recordLinkFieldId;
331+
}
332+
if (opts.fieldIdInLinkedTable !== undefined && opts.foreignTableRollupColumnId === undefined) {
333+
translated.foreignTableRollupColumnId = opts.fieldIdInLinkedTable;
334+
delete translated.fieldIdInLinkedTable;
335+
}
336+
return { type, typeOptions: translated };
337+
}
322338
// "singleSelect" is the public/REST API name; internal API uses "select" (mirrors multipleSelects → multiSelect).
323339
if (type === 'singleSelect' || type === 'select') {
324340
return {
@@ -592,13 +608,15 @@ export class AirtableClient {
592608

593609
const normalized = normalizeFieldType(fieldConfig.type, fieldConfig.typeOptions);
594610

611+
const config = { type: normalized.type };
612+
if (normalized.typeOptions && Object.keys(normalized.typeOptions).length > 0) {
613+
config.typeOptions = normalized.typeOptions;
614+
}
615+
595616
const payload = {
596617
tableId,
597618
name: fieldConfig.name,
598-
config: {
599-
type: normalized.type,
600-
typeOptions: normalized.typeOptions,
601-
},
619+
config,
602620
};
603621

604622
if (fieldConfig.description) {
@@ -634,11 +652,12 @@ export class AirtableClient {
634652

635653
const normalized = normalizeFieldType(config.type, config.typeOptions);
636654

637-
// Flat payload — matches real Airtable requests
638-
const payload = {
639-
type: normalized.type,
640-
typeOptions: normalized.typeOptions,
641-
};
655+
// Flat payload — matches real Airtable requests. Omit typeOptions when empty;
656+
// the internal API rejects typeOptions: {} for field types that have no options.
657+
const payload = { type: normalized.type };
658+
if (normalized.typeOptions && Object.keys(normalized.typeOptions).length > 0) {
659+
payload.typeOptions = normalized.typeOptions;
660+
}
642661

643662
const res = await this.auth.postForm(url, this._mutationParams(payload, appId), appId);
644663

packages/mcp-server/src/index.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,15 +488,19 @@ FIELD TYPES (fieldType parameter):
488488
489489
TYPE OPTIONS by fieldType:
490490
formula: { formulaText: "..." }
491-
rollup: { fieldIdInLinkedTable, recordLinkFieldId, resultType, referencedFieldIds }
492-
lookup: { recordLinkFieldId, fieldIdInLinkedTable }
491+
rollup: { relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET", formulaText: "SUM(values)" }
492+
(formulaText is REQUIRED — e.g. "SUM(values)", "COUNTA(values)", "IF(OR(values='X'),1,0)"))
493+
(old keys fieldIdInLinkedTable/recordLinkFieldId are auto-translated for backward compat)
494+
lookup: { relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET" }
495+
(old keys fieldIdInLinkedTable/recordLinkFieldId are auto-translated for backward compat)
493496
count: { recordLinkFieldId }
494497
number (integer): { format: "integer", negative: false }
495498
number (currency): { format: "currency", symbol: "$", precision: 2, negative: false }
496499
number (percent): { format: "percentV2", precision: 2, negative: false }
497500
date / dateTime: { dateFormat: "Local"|"us"|"european"|"iso"|"friendly", timeFormat: "12hour"|"24hour", timeZone: "UTC"|"client"|<IANA-tz>, shouldDisplayTimeZone: true|false, isDateTime: true (auto for dateTime) }
498501
singleSelect: { choices: [{ name: "Option A", color: "blueLight2" }] }
499502
multipleSelects: { choices: [{ name: "PC" }, { name: "Xbox", color: "greenLight2" }] }
503+
text / multilineText / checkbox / rating: omit typeOptions entirely — passing {} causes a 422
500504
501505
SELECT CHOICES: pass an array of { name, color? } objects — the client auto-converts to the object format the internal API requires and generates valid choice IDs.`,
502506
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -596,12 +600,15 @@ Works for formula, rollup, lookup, count, singleSelect, multipleSelects, number,
596600
COMMON typeOptions by fieldType:
597601
598602
formula: { formulaText: "IF({Field}, 1, 0)" }
599-
rollup: { relationColumnId: "fldXXX", formulaText: "SUM(values)" }
600-
lookup: { relationColumnId: "fldXXX", foreignTableRollupColumnId: "fldYYY" }
603+
rollup: { relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET", formulaText: "SUM(values)" }
604+
(formulaText is REQUIRED; old keys fieldIdInLinkedTable/recordLinkFieldId auto-translated)
605+
lookup: { relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET" }
606+
(old keys fieldIdInLinkedTable/recordLinkFieldId auto-translated)
601607
count: { recordLinkFieldId: "fldXXX" }
602608
singleSelect: { choices: [{ name: "Option A", color: "blueLight2" }] }
603609
multipleSelects: { choices: [{ name: "PC" }, { name: "Xbox", color: "greenLight2" }] }
604610
number: { format: "integer"|"decimal"|"currency"|"percentV2", precision: 2, symbol: "$", negative: false }
611+
text / multilineText / checkbox: omit typeOptions entirely — passing {} causes a 422
605612
606613
SELECT CHOICES — pass an array of { name, color? } objects; the client handles the internal format.
607614
@@ -1771,7 +1778,12 @@ const handlers = {
17711778
if (foundField.type !== 'formula') {
17721779
return { content: [{ type: 'text', text: JSON.stringify({ error: `Field ${fieldId} is type "${foundField.type}", not formula` }) }], isError: true };
17731780
}
1774-
const formulaText = foundField.typeOptions?.formulaText ?? '';
1781+
// Airtable's internal API has stored formula text at typeOptions.formulaText historically.
1782+
// Defensive fallback chain handles potential key renames in future API responses.
1783+
const formulaText = foundField.typeOptions?.formulaText
1784+
|| foundField.typeOptions?.formula
1785+
|| foundField.formula
1786+
|| '';
17751787
const fieldName = foundField.name ?? fieldId;
17761788
const description = foundField.description ?? '';
17771789
const resultType = foundField.typeOptions?.resultType ?? '';
@@ -1805,7 +1817,10 @@ const handlers = {
18051817
await mkdir(tableDir, { recursive: true });
18061818

18071819
for (const field of formulaFields) {
1808-
const formulaText = field.typeOptions?.formulaText ?? '';
1820+
const formulaText = field.typeOptions?.formulaText
1821+
|| field.typeOptions?.formula
1822+
|| field.formula
1823+
|| '';
18091824
const fieldName = field.name ?? field.id;
18101825
const description = field.description ?? '';
18111826
const resultType = field.typeOptions?.resultType ?? '';

0 commit comments

Comments
 (0)