Skip to content

Commit 7a2c274

Browse files
committed
fix(mcp): normalize select field choices to internal API format with id, choiceOrder, disableColors, and default handling
- Add id inside each choice value (required by internal API) when normalizing both array and object-form choices - Generate choiceOrder array automatically from choice keys when not provided - Set disableColors: false in typeOptions (required field) - Extract default (string for singleSelect, array for multipleSelects) from typeOptions and place at payload root level - Update
1 parent 67e5b2c commit 7a2c274

5 files changed

Lines changed: 368 additions & 56 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,18 +454,20 @@ Non-destructive field operations.
454454
| rollup | \`{ relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET", formulaText: "SUM(values)" }\` — **formulaText required**. Old keys \`fieldIdInLinkedTable\`/\`recordLinkFieldId\` are auto-translated. |
455455
| lookup | \`{ relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET" }\` — old keys auto-translated. |
456456
| count | \`{ recordLinkFieldId }\` |
457-
| singleSelect | \`{ choices: [{ name: "Option A", color: "blueLight2" }] }\` |
458-
| multipleSelects | \`{ choices: [{ name: "PC" }, { name: "Xbox", color: "greenLight2" }] }\` |
457+
| singleSelect | \`{ choices: [{ name: "Option A", color: "blue" }], default: "selXXX" }\` |
458+
| multipleSelects | \`{ choices: [{ name: "PC", color: "blue" }, { name: "Xbox", color: "cyan" }], default: ["selXXX"] }\` |
459459
| 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.
463+
Color names (confirmed from API): \`"blue"\`, \`"cyan"\`, \`"teal"\`, \`"green"\`, \`"yellow"\`, \`"orange"\`, \`"red"\`, \`"pink"\`, \`"purple"\`, \`"gray"\`.
464+
Set a default: \`"default": "selXXX"\` (string) for singleSelect, \`"default": ["selXXX"]\` (array) for multipleSelects.
463465
464466
To **add choices without losing existing ones**, call \`get_table_schema\` first to get existing choice IDs, then pass the full merged list:
465467
\`\`\`json
466468
{ "choices": [
467469
{ "id": "selXXXXXXXXXXXXXX", "name": "Existing Choice" },
468-
{ "name": "New Choice", "color": "pinkLight2" }
470+
{ "name": "New Choice", "color": "pink" }
469471
] }
470472
\`\`\`
471473
Choices **not** in the list are deleted. Omitting \`id\` creates a new choice.

packages/mcp-server/src/client.js

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,19 @@ function generateChoiceId() {
267267
*/
268268
function normalizeChoices(choices) {
269269
if (!choices) return choices;
270-
if (!Array.isArray(choices)) return choices; // already object-keyed, pass through
270+
if (!Array.isArray(choices)) {
271+
// Already object-keyed — ensure each value includes its own id (required by internal API).
272+
const result = {};
273+
for (const [key, val] of Object.entries(choices)) {
274+
result[key] = { id: key, ...val };
275+
}
276+
return result;
277+
}
278+
// Array form: generate IDs for new choices, preserve existing ids.
271279
const result = {};
272280
for (const { id, name, color, ...rest } of choices) {
273281
const key = id || generateChoiceId();
274-
result[key] = { name, ...(color ? { color } : {}), ...rest };
282+
result[key] = { id: key, name, ...(color ? { color } : {}), ...rest };
275283
}
276284
return result;
277285
}
@@ -312,12 +320,19 @@ function normalizeFieldType(type, typeOptions = {}) {
312320
};
313321
}
314322
// "multipleSelects" is the public/REST API name; internal API uses "multiSelect" (no "s").
315-
// Also normalize choices from array form to the object form the internal API requires.
323+
// Internal API requires: choices as keyed object with id inside each value, choiceOrder array,
324+
// disableColors bool. default (array of IDs) lives OUTSIDE typeOptions at the payload root.
316325
if (type === 'multipleSelects' || type === 'multiSelect') {
317-
return {
318-
type: 'multiSelect',
319-
typeOptions: { ...opts, ...(opts.choices ? { choices: normalizeChoices(opts.choices) } : {}) },
320-
};
326+
const { default: defaultVal, choices, choiceOrder: inputOrder, disableColors, ...restOpts } = opts;
327+
const normalizedChoices = choices ? normalizeChoices(choices) : null;
328+
const order = inputOrder || (normalizedChoices ? Object.keys(normalizedChoices) : null);
329+
const typeOpts = { ...restOpts };
330+
if (normalizedChoices) typeOpts.choices = normalizedChoices;
331+
if (order) typeOpts.choiceOrder = order;
332+
typeOpts.disableColors = disableColors !== undefined ? disableColors : false;
333+
const result = { type: 'multiSelect', typeOptions: typeOpts };
334+
if (defaultVal !== undefined) result.default = defaultVal;
335+
return result;
321336
}
322337
// Rollup and lookup use different key names in the internal API vs the public REST API.
323338
// Translate the public names (fieldIdInLinkedTable, recordLinkFieldId) to the internal
@@ -335,12 +350,20 @@ function normalizeFieldType(type, typeOptions = {}) {
335350
}
336351
return { type, typeOptions: translated };
337352
}
338-
// "singleSelect" is the public/REST API name; internal API uses "select" (mirrors multipleSelects → multiSelect).
353+
// "singleSelect" is the public/REST API name; internal API uses "select".
354+
// Internal API requires: choices as keyed object with id inside each value, choiceOrder array,
355+
// disableColors bool. default (single ID string) lives OUTSIDE typeOptions at the payload root.
339356
if (type === 'singleSelect' || type === 'select') {
340-
return {
341-
type: 'select',
342-
typeOptions: { ...opts, ...(opts.choices ? { choices: normalizeChoices(opts.choices) } : {}) },
343-
};
357+
const { default: defaultVal, choices, choiceOrder: inputOrder, disableColors, ...restOpts } = opts;
358+
const normalizedChoices = choices ? normalizeChoices(choices) : null;
359+
const order = inputOrder || (normalizedChoices ? Object.keys(normalizedChoices) : null);
360+
const typeOpts = { ...restOpts };
361+
if (normalizedChoices) typeOpts.choices = normalizedChoices;
362+
if (order) typeOpts.choiceOrder = order;
363+
typeOpts.disableColors = disableColors !== undefined ? disableColors : false;
364+
const result = { type: 'select', typeOptions: typeOpts };
365+
if (defaultVal !== undefined) result.default = defaultVal;
366+
return result;
344367
}
345368
return { type, typeOptions: opts };
346369
}
@@ -611,6 +634,7 @@ export class AirtableClient {
611634
const normalized = normalizeFieldType(fieldConfig.type, fieldConfig.typeOptions);
612635

613636
const config = { type: normalized.type };
637+
if (normalized.default !== undefined) config.default = normalized.default;
614638
if (normalized.typeOptions && Object.keys(normalized.typeOptions).length > 0) {
615639
config.typeOptions = normalized.typeOptions;
616640
}
@@ -657,6 +681,7 @@ export class AirtableClient {
657681
// Flat payload — matches real Airtable requests. Omit typeOptions when empty;
658682
// the internal API rejects typeOptions: {} for field types that have no options.
659683
const payload = { type: normalized.type };
684+
if (normalized.default !== undefined) payload.default = normalized.default;
660685
if (normalized.typeOptions && Object.keys(normalized.typeOptions).length > 0) {
661686
payload.typeOptions = normalized.typeOptions;
662687
}

packages/mcp-server/src/index.js

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,11 @@ const server = new Server(
135135
title: 'Airtable User MCP',
136136
version: PKG_VERSION,
137137
description:
138-
'Manage Airtable bases with 35+ tools: schema inspection, table CRUD, ' +
139-
'field CRUD (formula, rollup, lookup, count, url, dateTime, email, phone), ' +
140-
'view configuration (filters, sorts, grouping, visibility), formula validation, ' +
141-
'extension/block management, and tool profile control (read-only, safe-write, full, custom).',
138+
'Manage Airtable base structure with 62 tools: schema inspection, table/field/view CRUD, ' +
139+
'formula/rollup/lookup/count field management, view configuration (filters, sorts, groups, column layout), ' +
140+
'select field choices with color support, formula validation and file-based bulk editing, ' +
141+
'extension management, and granular tool profile control (read-only / safe-write / full / custom). ' +
142+
'Record read/write/delete is handled by the official Airtable MCP — this server focuses on schema and structure.',
142143
websiteUrl: 'https://github.com/Automations-Project/VSCode-Airtable-Formula/tree/main/packages/mcp-server',
143144
icons: [{ src: ICON_DATA_URI, mimeType: 'image/png', sizes: ['128x109'] }],
144145
},
@@ -172,7 +173,7 @@ const TOOLS = [
172173
// ── Read Tools ──
173174
{
174175
name: 'get_base_schema',
175-
description: 'Get the full schema of an Airtable base including all tables, fields, and views.',
176+
description: 'Get the full schema of an Airtable base all tables, fields (with typeOptions), and views in one call. Use this when you need fields or views; use `list_tables` when you only need table names/IDs (faster, lighter). Returns { tables: [...] }.',
176177
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
177178
inputSchema: {
178179
type: 'object',
@@ -185,7 +186,7 @@ const TOOLS = [
185186
},
186187
{
187188
name: 'list_tables',
188-
description: 'List all tables in an Airtable base with their IDs and names. Uses lightweight scaffolding data.',
189+
description: 'List all tables in a base with their IDs and names lightweight scaffolding call (no field data). Use this when you only need table IDs/names; use `get_base_schema` or `get_table_schema` when you also need fields or views. Returns [{ id, name }].',
189190
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
190191
inputSchema: {
191192
type: 'object',
@@ -198,7 +199,7 @@ const TOOLS = [
198199
},
199200
{
200201
name: 'get_table_schema',
201-
description: 'Get the full schema for a single table including all fields and views.',
202+
description: 'Get the full schema for a single table all fields (with typeOptions) and views. Use instead of `get_base_schema` when you only need one table (faster, less context). Use `list_fields` when you need fields only without view data. Returns { id, name, fields: [...], views: [...] }.',
202203
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
203204
inputSchema: {
204205
type: 'object',
@@ -212,7 +213,7 @@ const TOOLS = [
212213
},
213214
{
214215
name: 'list_fields',
215-
description: 'List fields (columns) in a table. Returns id, name, type, and typeOptions for each field. On large tables (100+ fields) use fieldType or nameContains to filter the response and reduce context size.',
216+
description: 'List fields in a table — returns id, name, type, and typeOptions per field. Use instead of `get_table_schema` when you need fields only (no view data). Use `fieldType` or `nameContains` filters on large tables to reduce context size. Returns [{ id, name, type, typeOptions }].',
216217
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
217218
inputSchema: {
218219
type: 'object',
@@ -498,11 +499,16 @@ TYPE OPTIONS by fieldType:
498499
number (currency): { format: "currency", symbol: "$", precision: 2, negative: false }
499500
number (percent): { format: "percentV2", precision: 2, negative: false }
500501
date / dateTime: { dateFormat: "Local"|"us"|"european"|"iso"|"friendly", timeFormat: "12hour"|"24hour", timeZone: "UTC"|"client"|<IANA-tz>, shouldDisplayTimeZone: true|false, isDateTime: true (auto for dateTime) }
501-
singleSelect: { choices: [{ name: "Option A", color: "blueLight2" }] }
502-
multipleSelects: { choices: [{ name: "PC" }, { name: "Xbox", color: "greenLight2" }] }
502+
singleSelect: { choices: [{ name: "Option A", color: "blue" }], default: "selXXX" }
503+
multipleSelects: { choices: [{ name: "PC", color: "blue" }, { name: "Xbox", color: "cyan" }], default: ["selXXX"] }
503504
text / multilineText / checkbox / rating: omit typeOptions entirely — passing {} causes a 422
504505
505-
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.`,
506+
SELECT CHOICES:
507+
- Pass choices as an array [{ name, color? }] or as an object { selXXX: { name, color? } }.
508+
- The client auto-adds id inside each choice value, generates choiceOrder, and sets disableColors: false.
509+
- Color names (confirmed): "blue", "cyan", "teal", "green", "yellow", "orange", "red", "pink", "purple", "gray".
510+
- "default" sets the pre-selected value: string ID for singleSelect, array of IDs for multipleSelects.
511+
- To add/remove choices without losing existing ones, call get_table_schema first and include ALL choices in the update.`,
506512
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
507513
inputSchema: {
508514
type: 'object',
@@ -530,7 +536,7 @@ SELECT CHOICES: pass an array of { name, color? } objects — the client auto-co
530536
},
531537
{
532538
name: 'create_formula_field',
533-
description: 'Create a new formula field in a table. Shorthand for create_field with type "formula".',
539+
description: 'Create a new formula field — shorthand for `create_field` with type "formula". Use `create_field` for all other field types (singleSelect, rollup, number, etc.). Returns { columnId }.',
534540
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
535541
inputSchema: {
536542
type: 'object',
@@ -605,18 +611,21 @@ COMMON typeOptions by fieldType:
605611
lookup: { relationColumnId: "fldLINK", foreignTableRollupColumnId: "fldTARGET" }
606612
(old keys fieldIdInLinkedTable/recordLinkFieldId auto-translated)
607613
count: { recordLinkFieldId: "fldXXX" }
608-
singleSelect: { choices: [{ name: "Option A", color: "blueLight2" }] }
609-
multipleSelects: { choices: [{ name: "PC" }, { name: "Xbox", color: "greenLight2" }] }
614+
singleSelect: { choices: [{ name: "Option A", color: "blue" }], default: "selXXX" }
615+
multipleSelects: { choices: [{ name: "PC", color: "blue" }, { name: "Xbox", color: "cyan" }], default: ["selXXX"] }
610616
number: { format: "integer"|"decimal"|"currency"|"percentV2", precision: 2, symbol: "$", negative: false }
611617
text / multilineText / checkbox: omit typeOptions entirely — passing {} causes a 422
612618
613-
SELECT CHOICES — pass an array of { name, color? } objects; the client handles the internal format.
619+
SELECT CHOICES:
620+
- Pass choices as array [{ name, color? }] or object { selXXX: { name, color? } }.
621+
- Color names (confirmed): "blue", "cyan", "teal", "green", "yellow", "orange", "red", "pink", "purple", "gray".
622+
- "default" = pre-selected value: string ID for singleSelect, array of IDs for multipleSelects.
614623
615624
ADDING TO AN EXISTING SELECT FIELD (merge, not replace):
616625
Choices not in the list are DELETED. To add without losing existing choices:
617626
1. Call get_table_schema — each existing choice has { id, name, color }
618627
2. Pass the full list: existing entries WITH their id, new entries WITHOUT:
619-
{ choices: [{ id: "selXXXXXXXXXXXXXX", name: "Existing" }, { name: "New Choice", color: "pinkLight2" }] }
628+
{ choices: [{ id: "selXXXXXXXXXXXXXX", name: "Existing" }, { name: "New Choice", color: "pink" }] }
620629
621630
REPLACING ALL CHOICES: just pass the new choices without any IDs.`,
622631
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -637,7 +646,7 @@ REPLACING ALL CHOICES: just pass the new choices without any IDs.`,
637646
},
638647
{
639648
name: 'update_formula_field',
640-
description: 'Update the formula text of an existing formula field. Shorthand for update_field_config with type "formula".',
649+
description: 'Update the formula body of an existing formula field — shorthand for `update_field_config` with type "formula". Automatically preserves existing format/precision typeOptions (e.g. percentV2, precision). Use `update_field_config` to change the field type or other typeOptions.',
641650
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
642651
inputSchema: {
643652
type: 'object',
@@ -671,7 +680,7 @@ REPLACING ALL CHOICES: just pass the new choices without any IDs.`,
671680
},
672681
{
673682
name: 'delete_field',
674-
description: 'Delete a field from an Airtable table. Requires both fieldId AND the expected field name as a safety guard. First checks for downstream dependencies — if found, returns dependency info instead of deleting. Set force=true to delete even with dependencies.',
683+
description: 'Delete a field from an Airtable table. Requires fieldId AND expectedName as a safety guard — deletion is refused if the name does not match. ⚠️ Irreversible: deleted field data is permanently lost and cannot be recovered. Always checks downstream dependencies first (formula fields, lookups, rollups referencing this field); returns dependency info without deleting unless force=true.',
675684
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
676685
inputSchema: {
677686
type: 'object',
@@ -933,7 +942,7 @@ EXAMPLES:
933942
},
934943
{
935944
name: 'show_or_hide_view_columns',
936-
description: 'Show or hide specific fields (columns) in a view. Pass an array of column IDs and a single visibility flag — every ID in the array is set to that visibility. To toggle many fields at once, send the full set in one call (no separate "show all" / "hide all" tool exists today; that lives in 2.4.0+).',
945+
description: 'Show or hide specific columns in a view without affecting others. Pass field IDs + a visibility flag — every listed ID is set to that state, all other columns are untouched. Use `set_view_columns` instead when you want to define the full visible set from scratch. Use `show_or_hide_all_columns` to bulk-toggle every column at once.',
937946
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
938947
inputSchema: {
939948
type: 'object',
@@ -1115,7 +1124,7 @@ For section reorders, targetIndex is into the table's top-level mixed viewOrder;
11151124
// ── View Columns (bulk visibility, ordering, freezing) ──
11161125
{
11171126
name: 'set_view_columns',
1118-
description: 'One-shot view-column setup. Hides every column in the view, then shows only `visibleColumnIds` in the order given (left-to-right), then optionally sets the frozen-column divider. Use this to turn a brand-new view from "all 168 fields visible" into a curated layout in a single tool call.',
1127+
description: 'One-shot view-column reset: hides every column then shows only `visibleColumnIds` in the given left-to-right order, with optional freeze. Use this for fresh view setup or full layout rewrites. Use `show_or_hide_view_columns` when you only want to toggle specific columns without touching the rest.',
11191128
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11201129
inputSchema: {
11211130
type: 'object',
@@ -1131,7 +1140,7 @@ For section reorders, targetIndex is into the table's top-level mixed viewOrder;
11311140
},
11321141
{
11331142
name: 'show_or_hide_all_columns',
1134-
description: 'Show or hide every column in a view in one call. Use `set_view_columns` for "hide all then show these specific ones" — this tool is the bulk all-or-nothing primitive.',
1143+
description: 'Show or hide every column in a view in one call. Use when you want a clean all-visible or all-hidden baseline. Use `set_view_columns` when you want to show a specific subset (it hides all then shows only the listed IDs). Use `show_or_hide_view_columns` for selective per-column toggles.',
11351144
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11361145
inputSchema: {
11371146
type: 'object',
@@ -1146,7 +1155,7 @@ For section reorders, targetIndex is into the table's top-level mixed viewOrder;
11461155
},
11471156
{
11481157
name: 'move_visible_columns',
1149-
description: 'Move one or more columns to a new position in the *visible-only* index. Index 0 is the leftmost visible column. Distinct from `reorder_view_fields` (which writes the full overall order — visible + hidden) and `move_overall_columns` (which also operates on overall index but accepts a partial array of columns to move).',
1158+
description: 'Move columns by visible-only index (index 0 = leftmost shown column, hidden columns not counted). Use when you want to position relative to what the user sees. Use `move_overall_columns` when you need to position relative to the full underlying column order including hidden fields. ⚠️ The API preserves existing relative order of supplied IDs — to place columns in a custom sequence, issue one call per column with incrementing targets.',
11501159
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
11511160
inputSchema: {
11521161
type: 'object',

0 commit comments

Comments
 (0)