Skip to content

Airtable records#1458

Draft
JonasJesus42 wants to merge 4 commits intomainfrom
airtable-records
Draft

Airtable records#1458
JonasJesus42 wants to merge 4 commits intomainfrom
airtable-records

Conversation

@JonasJesus42
Copy link
Contributor

@JonasJesus42 JonasJesus42 commented Nov 10, 2025

What is this Contribution About?

Please provide a brief description of the changes or enhancements you are proposing in this pull request.

Issue Link

Please link to the relevant issue that this pull request addresses:

Loom Video

Record a quick screencast describing your changes to help the team understand and review your contribution. This will greatly assist in the review process.

Demonstration Link

Provide a link to a branch or environment where this pull request can be tested and seen in action.

Summary by CodeRabbit

  • New Features

    • Bulk record creation for Airtable tables (multi-record uploads with optional typecast).
  • Refactor

    • Unified response envelope for all Airtable actions/loaders, standardizing success and error shapes.
    • Improved error handling and messages across flows: consistent auth/permission/validation statuses, explicit duplicate/empty-name feedback, and clearer 400/500 error responses.

@github-actions
Copy link
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 0.130.10 update
  • 🎉 for Minor 0.131.0 update
  • 🚀 for Major 1.0.0 update

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 10, 2025

Walkthrough

Refactors multiple Airtable actions and loaders to return a unified MCPResponse envelope; adds a new createRecords action supporting batch creation; updates client types for multi-record POSTs; and converts error handling to structured MCPResponse objects with try/catch-wrapped API calls.

Changes

Cohort / File(s) Summary
Type Definitions
airtable/utils/types.ts
Adds MCPErrorResponse, MCPSuccessResponse<T>, and MCPResponse<T> union to standardize response envelopes.
Client Type Updates
airtable/utils/client.ts
Changes POST /v0/:baseId/:tableId to accept { records: CreateRecordBody[]; typecast?: boolean } and return { records: AirtableRecord[] } for batch creation.
New Action
airtable/actions/createRecords.ts
New default-export action to create up to 10 records via OAuth with permission checks, input validation, POST to Airtable, MCPResponse-wrapped success and error returns.
Action Refactoring (Fields/Tables)
airtable/actions/createField.ts, airtable/actions/createTable.ts, airtable/actions/updateField.ts, airtable/actions/updateTable.ts
Convert return types to Promise<MCPResponse<T>>; replace Response/throw returns with MCPResponse error objects; wrap remote calls in try/catch; return { data } on success; handle duplicate/empty-field-name errors with structured details.
Record Actions
airtable/actions/deleteRecords.ts, airtable/actions/updateRecords.ts
Standardize to MCPResponse<{ records: ... }>; unified MCPResponse error handling for auth, permissions, validation, HTTP failures; API calls wrapped in try/catch.
Loaders Refactoring
airtable/loaders/getBaseSchema.ts, airtable/loaders/getRecord.ts, airtable/loaders/listBases.ts, airtable/loaders/listRecords.ts, airtable/loaders/searchRecords.ts
Change loaders to return Promise<MCPResponse<T>>; convert OAuth/permission/API error paths to MCPResponse errors; wrap logic in try/catch; return { data } on success; adjust permission-envelope handling.
Manifest Updates
airtable/manifest.gen.ts
Reorders action imports/mappings to include createRecords and adjust subsequent action aliases/indices.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Handler as Action/Loader
    participant Auth as OAuth Check
    participant Perm as Permissioning
    participant Airtable as Airtable API

    Client->>Handler: invoke(props)
    Handler->>Auth: check ctx.client
    alt No OAuth
        Auth-->>Handler: missing
        Handler-->>Client: MCPResponse { error, status: 401 }
    else OAuth present
        Handler->>Perm: validatePermissions(...)
        alt Permission denied
            Perm-->>Handler: denied / envelope error
            Handler-->>Client: MCPResponse { error, status: 403 }
        else Permission granted
            Handler->>Airtable: try { HTTP request (GET/POST/PATCH/DELETE) }
            alt HTTP OK
                Airtable-->>Handler: JSON payload
                Handler-->>Client: MCPResponse { data: parsedJSON }
            else HTTP error
                Airtable-->>Handler: non-OK
                Handler-->>Client: MCPResponse { error: message, status }
            end
        end
    end
    Note over Handler: catch -> MCPResponse { error, status: 500 }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Attention areas:
    • confirm consistent MCPResponse shaping across all branches (success/error)
    • verify DUPLICATE_OR_EMPTY_FIELD_NAME handling and details shape in create/update field flows
    • review new createRecords.ts limits, input validation, and use of updated client POST signature
    • ensure manifest reordering didn't break action aliases/indices

Suggested reviewers

  • mcandeia
  • aka-sacci-ccr

Poem

🐰 I wrapped the outputs neat and small,
Enveloped errors, data for all.
Batches bundled, permissions checked,
Try/catch hugged each network spec.
Hop — a record made, no tumble or fall.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description contains only the template placeholder text with no actual implementation details, issue links, or concrete information about the changes. Replace the template placeholders with a brief description of changes, a link to the related issue, and optionally include a Loom video or demonstration link for testing.
Title check ❓ Inconclusive The title 'Airtable records' is vague and does not clearly convey the primary changes in the pull request. Use a more descriptive title that reflects the main objective, such as 'Refactor Airtable actions to use unified MCPResponse envelope' or 'Add MCPResponse error handling to Airtable operations'.
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch airtable-records

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
airtable/actions/updateRecords.ts (1)

64-135: Handle the MCPResponse envelope from validatePermissions.

ctx.invoke["airtable"].loaders.permissioning.validatePermissions now returns an MCPResponse. Accessing hasPermission directly on the envelope skips both error handling and the actual payload, so a denied or failed validation now falls through and the update runs anyway. Please unwrap the response, propagate any error early, and then examine the returned data. For example:

- const validationResult = await ctx.invoke["airtable"].loaders.permissioning.validatePermissions({ … });
- if ("hasPermission" in validationResult && !validationResult.hasPermission) {
-   return { error: validationResult.message || "Access denied", status: 403 };
- }
+ const validationResponse =
+   await ctx.invoke["airtable"].loaders.permissioning.validatePermissions({ … });
+ if ("error" in validationResponse) {
+   return {
+     error: validationResponse.error,
+     status: validationResponse.status ?? 403,
+   };
+ }
+ const validationResult = validationResponse.data;
+ if (
+   validationResult &&
+   "hasPermission" in validationResult &&
+   !validationResult.hasPermission
+ ) {
+   return {
+     error: validationResult.message || "Access denied",
+     status: 403,
+   };
+ }

This preserves the new MCP contract and keeps unauthorized updates from proceeding.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a1d44a5 and e600ec9.

📒 Files selected for processing (15)
  • airtable/actions/createField.ts (4 hunks)
  • airtable/actions/createRecords.ts (1 hunks)
  • airtable/actions/createTable.ts (4 hunks)
  • airtable/actions/deleteRecords.ts (3 hunks)
  • airtable/actions/updateField.ts (4 hunks)
  • airtable/actions/updateRecords.ts (4 hunks)
  • airtable/actions/updateTable.ts (4 hunks)
  • airtable/loaders/getBaseSchema.ts (2 hunks)
  • airtable/loaders/getRecord.ts (2 hunks)
  • airtable/loaders/listBases.ts (2 hunks)
  • airtable/loaders/listRecords.ts (2 hunks)
  • airtable/loaders/searchRecords.ts (2 hunks)
  • airtable/manifest.gen.ts (2 hunks)
  • airtable/utils/client.ts (1 hunks)
  • airtable/utils/types.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (13)
airtable/actions/updateTable.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Table (35-46)
airtable/actions/updateField.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Field (26-32)
airtable/loaders/listRecords.ts (1)
airtable/utils/types.ts (4)
  • MCPResponse (13-13)
  • ListRecordsResponse (82-85)
  • ValidationResult (190-195)
  • ValidationFilterResult (175-182)
airtable/loaders/listBases.ts (1)
airtable/utils/types.ts (4)
  • MCPResponse (13-13)
  • ListBasesResponse (49-56)
  • ValidationResult (190-195)
  • ValidationFilterResult (175-182)
airtable/loaders/searchRecords.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • ListRecordsResponse (82-85)
airtable/actions/deleteRecords.ts (1)
airtable/utils/types.ts (1)
  • MCPResponse (13-13)
airtable/utils/client.ts (1)
airtable/utils/types.ts (2)
  • CreateRecordBody (87-90)
  • AirtableRecord (19-23)
airtable/actions/createField.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Field (26-32)
airtable/loaders/getBaseSchema.ts (1)
airtable/utils/types.ts (4)
  • MCPResponse (13-13)
  • BaseSchemaResponse (59-62)
  • ValidationResult (190-195)
  • ValidationFilterResult (175-182)
airtable/actions/createRecords.ts (1)
airtable/utils/types.ts (4)
  • FieldSet (15-17)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
  • CreateRecordBody (87-90)
airtable/actions/createTable.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Table (35-46)
airtable/loaders/getRecord.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
airtable/actions/updateRecords.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
airtable/actions/updateTable.ts (1)

51-63: Unwrap validatePermissions MCPResponse before proceeding

Line 52: validatePermissions now returns an MCPResponse envelope. As written, any { error, status } response (including outright permission failures) falls through because "hasPermission" is never inspected and the API call executes anyway. Please unwrap the envelope, bail out on the error branch, and only read data.hasPermission when present.

-  const validationResult = await ctx.invoke["airtable"].loaders.permissioning
-    .validatePermissions({
+  const validationResponse = await ctx.invoke["airtable"].loaders.permissioning
+    .validatePermissions({
       mode: "check",
       baseId: props.baseId,
       tableIdOrName: props.tableId,
     });
-
-  if ("hasPermission" in validationResult && !validationResult.hasPermission) {
+  if ("error" in validationResponse) {
+    return {
+      error: validationResponse.error ?? "Access denied",
+      status: validationResponse.status ?? 403,
+    };
+  }
+  const validationResult = validationResponse.data;
+  if (
+    validationResult &&
+    "hasPermission" in validationResult &&
+    !validationResult.hasPermission
+  ) {
     return {
       error: validationResult.message || "Access denied",
       status: 403,
     };
   }
airtable/actions/deleteRecords.ts (1)

40-52: Return early on validatePermissions MCPResponse errors

Line 42: Like the other modules, this still assumes validatePermissions returns a bare result. Any { error, status } envelope skips your check, so deletions can run without confirmed access. Please unwrap the MCPResponse, return the error/status when present, and only inspect data.hasPermission thereafter.

-  const validationResult = await ctx.invoke["airtable"].loaders.permissioning
-    .validatePermissions({
+  const validationResponse = await ctx.invoke["airtable"].loaders.permissioning
+    .validatePermissions({
       mode: "check",
       baseId: props.baseId,
       tableIdOrName: props.tableId,
     });
-
-  if ("hasPermission" in validationResult && !validationResult.hasPermission) {
+  if ("error" in validationResponse) {
+    return {
+      error: validationResponse.error ?? "Access denied",
+      status: validationResponse.status ?? 403,
+    };
+  }
+  const validationResult = validationResponse.data;
+  if (
+    validationResult &&
+    "hasPermission" in validationResult &&
+    !validationResult.hasPermission
+  ) {
     return {
       error: validationResult.message || "Access denied",
       status: 403,
     };
   }
♻️ Duplicate comments (3)
airtable/loaders/getRecord.ts (1)

42-56: MCPResponse still needs unwrapping before the permission check

Line 44: Per the MCP refactor, validatePermissions returns an MCPResponse. Treating it as a bare result means loader errors and permission denials slip through. Please unwrap the response, return early on the error branch, and only inspect data.hasPermission once confirmed.

-    const validationResult = await ctx.invoke["airtable"].loaders.permissioning
-      .validatePermissions({
+    const validationResponse = await ctx.invoke["airtable"].loaders.permissioning
+      .validatePermissions({
         mode: "check",
         baseId: props.baseId,
         tableIdOrName: props.tableId,
       });
-
-    if (
-      "hasPermission" in validationResult && !validationResult.hasPermission
-    ) {
+    if ("error" in validationResponse) {
+      return {
+        error: validationResponse.error ?? "Access denied",
+        status: validationResponse.status ?? 403,
+      };
+    }
+    const validationResult = validationResponse.data;
+    if (
+      validationResult &&
+      "hasPermission" in validationResult &&
+      !validationResult.hasPermission
+    ) {
       return {
         error: validationResult.message || "Access denied",
         status: 403,
       };
     }
airtable/actions/createRecords.ts (1)

96-104: Preserve per-record typecast in the request payload

Line 98: We expose RecordToCreate.typecast, but the mapping drops it, so callers relying on per-record typecasting silently stop working. Carry the flag through to CreateRecordBody when present.

-    const recordsToCreate: CreateRecordBody[] = records.map((record) => ({
-      fields: record.fields,
-    }));
+    const recordsToCreate: CreateRecordBody[] = records.map((record) => {
+      const recordBody: CreateRecordBody = { fields: record.fields };
+      if (record.typecast !== undefined) {
+        recordBody.typecast = record.typecast;
+      }
+      return recordBody;
+    });
airtable/loaders/searchRecords.ts (1)

106-114: Escape searchTerm before building filterByFormula.
Line 109 and Line 113 inject searchTerm into the formula verbatim. When the user includes " or \, Airtable rejects the request with HTTP 422, so search stops working for those inputs. Escape these characters first and reuse the sanitized value everywhere you template the formula.

-    let filterByFormula = "";
+    let filterByFormula = "";
+    const escapedSearchTerm = searchTerm
+      .replace(/\\/g, "\\\\")
+      .replace(/"/g, '\\"');
     if (searchFields && searchFields.length > 0) {
       const fieldFormulas = searchFields.map(
-        (field) => `SEARCH("${searchTerm}", {${field}})`,
+        (field) => `SEARCH("${escapedSearchTerm}", {${field}})`,
       );
       filterByFormula = `OR(${fieldFormulas.join(",")})`;
     } else {
-      filterByFormula = `SEARCH("${searchTerm}", {*})`;
+      filterByFormula = `SEARCH("${escapedSearchTerm}", {*})`;
     }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e600ec9 and 94b8603.

📒 Files selected for processing (12)
  • airtable/actions/createField.ts (4 hunks)
  • airtable/actions/createRecords.ts (1 hunks)
  • airtable/actions/createTable.ts (4 hunks)
  • airtable/actions/deleteRecords.ts (3 hunks)
  • airtable/actions/updateField.ts (4 hunks)
  • airtable/actions/updateRecords.ts (4 hunks)
  • airtable/actions/updateTable.ts (4 hunks)
  • airtable/loaders/getBaseSchema.ts (2 hunks)
  • airtable/loaders/getRecord.ts (2 hunks)
  • airtable/loaders/listBases.ts (2 hunks)
  • airtable/loaders/listRecords.ts (2 hunks)
  • airtable/loaders/searchRecords.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • airtable/actions/createTable.ts
🧰 Additional context used
🧬 Code graph analysis (11)
airtable/loaders/getRecord.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
airtable/actions/createField.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Field (26-32)
airtable/actions/createRecords.ts (1)
airtable/utils/types.ts (4)
  • FieldSet (15-17)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
  • CreateRecordBody (87-90)
airtable/loaders/getBaseSchema.ts (2)
airtable/utils/types.ts (4)
  • MCPResponse (13-13)
  • BaseSchemaResponse (59-62)
  • ValidationResult (190-195)
  • ValidationFilterResult (175-182)
airtable/mod.ts (1)
  • Props (30-54)
airtable/actions/updateRecords.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
airtable/actions/updateTable.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Table (35-46)
airtable/actions/deleteRecords.ts (1)
airtable/utils/types.ts (1)
  • MCPResponse (13-13)
airtable/loaders/listRecords.ts (2)
airtable/utils/types.ts (4)
  • MCPResponse (13-13)
  • ListRecordsResponse (82-85)
  • ValidationResult (190-195)
  • ValidationFilterResult (175-182)
airtable/mod.ts (1)
  • Props (30-54)
airtable/actions/updateField.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • Field (26-32)
airtable/loaders/searchRecords.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • ListRecordsResponse (82-85)
airtable/loaders/listBases.ts (1)
airtable/utils/types.ts (4)
  • MCPResponse (13-13)
  • ListBasesResponse (49-56)
  • ValidationResult (190-195)
  • ValidationFilterResult (175-182)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 94b8603 and d2a0b12.

📒 Files selected for processing (4)
  • airtable/actions/createRecords.ts (1 hunks)
  • airtable/loaders/getRecord.ts (2 hunks)
  • airtable/loaders/listRecords.ts (2 hunks)
  • airtable/loaders/searchRecords.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
airtable/loaders/getRecord.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
airtable/actions/createRecords.ts (1)
airtable/utils/types.ts (4)
  • FieldSet (15-17)
  • MCPResponse (13-13)
  • AirtableRecord (19-23)
  • CreateRecordBody (87-90)
airtable/loaders/listRecords.ts (1)
airtable/utils/types.ts (3)
  • MCPResponse (13-13)
  • ListRecordsResponse (82-85)
  • ValidationFilterResult (175-182)
airtable/loaders/searchRecords.ts (1)
airtable/utils/types.ts (2)
  • MCPResponse (13-13)
  • ListRecordsResponse (82-85)
🪛 GitHub Actions: ci
airtable/loaders/getRecord.ts

[error] 51-51: Type '{ error: string | undefined; status: any; }' is not assignable to type 'MCPResponse'.


[error] 57-57: Property 'data' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.

airtable/actions/createRecords.ts

[error] 67-67: Property 'status' does not exist on type 'ValidationFilterResult | ValidationErrorResult | { error: string; }'.


[error] 71-71: Property 'data' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.


[error] 53-53: Property 'status' does not exist on type 'ValidationFilterResult | ValidationErrorResult | { error: string; }'.

airtable/loaders/listRecords.ts

[error] 97-97: Property 'status' does not exist on type 'ValidationFilterResult | ValidationErrorResult | { error: string; }'.


[error] 101-101: Property 'data' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.


[error] 105-105: Property 'status' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.

Comment on lines +56 to +118
const validationResponse = await ctx.invoke["airtable"].loaders.permissioning
.validatePermissions({
mode: "check",
baseId: props.baseId,
tableIdOrName: props.tableId,
});

// Desembrulhar o MCPResponse envelope
if ("error" in validationResponse) {
return {
error: validationResponse.error ?? "Access denied",
status: validationResponse.status ?? 403,
};
}

const validationResult = validationResponse.data;
if (
validationResult &&
"hasPermission" in validationResult &&
!validationResult.hasPermission
) {
return {
error: validationResult.message || "Access denied",
status: 403,
};
}

const { baseId, tableId, records, typecast } = props;

if (!records || records.length === 0) {
return {
error: "At least one record is required",
status: 400,
};
}

if (records.length > 10) {
return {
error: "Maximum 10 records can be created at once",
status: 400,
};
}

const invalidRecords = records.filter((record) =>
!record.fields || typeof record.fields !== "object"
);
if (invalidRecords.length > 0) {
return {
error: "All records must have a valid 'fields' object",
status: 400,
};
}

try {
const recordsToCreate: CreateRecordBody[] = records.map((record) => {
const recordBody: CreateRecordBody = {
fields: record.fields,
};
if (record.typecast !== undefined) {
recordBody.typecast = record.typecast;
}
return recordBody;
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix permission loader typing mismatch.

validatePermissions is still typed as PermissionCheck | ValidationFilterResult | ValidationErrorResult, so it has neither .data nor .status. Accessing those members (lines 63‑79) causes the build to fail, as shown in CI, and the permission result never unwraps correctly. Please update the permission loader/manifest to actually return an MCPResponse before reading .data, or adjust this call site to handle the existing union shape (gate on error and fall back to the original object). Until then the action won’t compile or enforce permission errors.

🧰 Tools
🪛 GitHub Actions: ci

[error] 67-67: Property 'status' does not exist on type 'ValidationFilterResult | ValidationErrorResult | { error: string; }'.


[error] 71-71: Property 'data' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.

🤖 Prompt for AI Agents
In airtable/actions/createRecords.ts around lines 56 to 118, the call to
validatePermissions assumes an MCPResponse with .data and .status but the loader
currently returns a union (PermissionCheck | ValidationFilterResult |
ValidationErrorResult), so accessing .data/.status breaks the build; either
update the permission loader/manifest to return an MCPResponse or change this
call site to narrow the union before unwrapping: first check for the presence of
"error" and return using its error/status, then check for "hasPermission" (the
legacy shape) and handle its message/hasPermission fields, and finally if an
MCPResponse shape is possible check for "data" and use data/status; ensure all
branches return a consistent error/status shape so the action compiles and
permission failures are enforced.

Comment on lines +41 to +91
try {
const validationResponse = await ctx.invoke["airtable"].loaders
.permissioning.validatePermissions({
mode: "check",
baseId: props.baseId,
tableIdOrName: props.tableId,
});

const response = await ctx.client["GET /v0/:baseId/:tableId/:recordId"](
props,
);
// Desembrulhar o MCPResponse envelope
if ("error" in validationResponse) {
return {
error: validationResponse.error,
status: validationResponse.status ?? 403,
};
}

if (!response.ok) {
throw new Error(`Error getting record: ${response.statusText}`);
}
const validationResult = validationResponse.data;
if (
validationResult &&
"hasPermission" in validationResult &&
!validationResult.hasPermission
) {
return {
error: validationResult.message || "Access denied",
status: 403,
};
}

const response = await ctx.client["GET /v0/:baseId/:tableId/:recordId"](
props,
);

return await response.json();
if (!response.ok) {
return {
error: `Error getting record: ${response.statusText}`,
status: response.status,
};
}

const data = await response.json();
return {
data,
};
} catch (err) {
return {
error: `Error getting record: ${
err instanceof Error ? err.message : String(err)
}`,
status: 500,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Restore correct permission unwrap.

Here too validatePermissions still returns the legacy union (PermissionCheck | ValidationFilterResult | ValidationErrorResult), so .data/.status are undefined. The new branch therefore fails type-checking (see CI) and would never block permission errors. Please either change the loader/manifest to emit MCPResponse<PermissionCheck> or update this code to inspect the existing union before proceeding.

🧰 Tools
🪛 GitHub Actions: ci

[error] 51-51: Type '{ error: string | undefined; status: any; }' is not assignable to type 'MCPResponse'.


[error] 57-57: Property 'data' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.

🤖 Prompt for AI Agents
In airtable/loaders/getRecord.ts around lines 41 to 91, the code assumes
validatePermissions returns an MCPResponse with .data as PermissionCheck but the
actual return is a union (PermissionCheck | ValidationFilterResult |
ValidationErrorResult), so .data/.status may be undefined and permission
failures are not handled; update the unwrap logic to inspect the union shape:
first check for the error-shaped ValidationErrorResult (e.g., presence of
.error/.status) and return accordingly, then check for ValidationFilterResult
and handle its semantics, and finally check for PermissionCheck and use its
hasPermission/message fields to allow or block access; alternatively, if you
prefer changing the manifest/loader, adjust validatePermissions to emit
MCPResponse<PermissionCheck> so this file can safely use .data as
PermissionCheck.

Comment on lines +86 to +140
try {
const validationResponse = await ctx.invoke["airtable"].loaders
.permissioning.validatePermissions({
mode: "filter",
props: { ...props, tableIdOrName: props.tableId },
});

// Desembrulhar o MCPResponse envelope
if ("error" in validationResponse) {
return {
error: validationResponse.error ?? "Access denied",
status: validationResponse.status ?? 403,
};
}

const propsValidationResult = validationResponse.data;
if (!propsValidationResult) {
return {
error: "Permission validation failed",
status: validationResponse.status ?? 500,
};
}

if ("error" in propsValidationResult && propsValidationResult.error) {
return {
error: propsValidationResult.error,
status: 403,
};
}

const filterResult = propsValidationResult as ValidationFilterResult;
const validatedProps = (filterResult.filteredProps || props) as Props;

const response = await ctx.client["GET /v0/:baseId/:tableId"](
validatedProps,
);

if (!response.ok) {
return {
error: `Error listing records: ${response.statusText}`,
status: response.status,
};
}

const data = await response.json();
return {
data,
};
} catch (err) {
return {
error: `Error listing records: ${
err instanceof Error ? err.message : String(err)
}`,
status: 500,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Handle the actual validation result shape.

validatePermissions still exposes ValidationFilterResult | ValidationErrorResult | { error: string; }, so reading .status and .data (lines 94‑106) won’t compile and skips error envelopes. Please update the permission loader/manifest to return MCPResponse<ValidationFilterResult> (with .data) or adjust this block to work with the current union before using the result.

🧰 Tools
🪛 GitHub Actions: ci

[error] 97-97: Property 'status' does not exist on type 'ValidationFilterResult | ValidationErrorResult | { error: string; }'.


[error] 101-101: Property 'data' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.


[error] 105-105: Property 'status' does not exist on type 'PermissionCheck | ValidationFilterResult | FilteredOperationResult'.

🤖 Prompt for AI Agents
In airtable/loaders/listRecords.ts around lines 86 to 140, the code assumes
validationResponse has .data and .status but validatePermissions returns a union
(ValidationFilterResult | ValidationErrorResult | { error: string }), so
accessing .data/.status unguarded will not compile and can skip error envelopes;
update either the permission loader signature to return
MCPResponse<ValidationFilterResult> or (recommended) change this block to narrow
the union before using .data/.status: check for the various shapes (e.g., if
("error" in validationResponse && validationResponse.error) return that
envelope; else if ("data" in validationResponse) use validationResponse.data;
else if ("status" in validationResponse) use validationResponse.status or
default values), add explicit type guards/casts to obtain ValidationFilterResult
before treating it as such, and ensure all branches return proper { error,
status } or { data } to satisfy the compiler.

Comment on lines +87 to +136
try {
const validationResult = await ctx.invoke["airtable"].loaders.permissioning
.validatePermissions({
mode: "check",
baseId: props.baseId,
tableIdOrName: props.tableId,
});

if (
"hasPermission" in validationResult && !validationResult.hasPermission
) {
return {
error: validationResult.message || "Access denied",
status: 403,
};
}

const { searchTerm, searchFields, ...otherProps } = props;

// Escape backslashes and double quotes in searchTerm to prevent formula injection
const escapedSearchTerm = searchTerm
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');

let filterByFormula = "";
if (searchFields && searchFields.length > 0) {
const fieldFormulas = searchFields.map(
(field) => `SEARCH("${escapedSearchTerm}", {${field}})`,
);
filterByFormula = `OR(${fieldFormulas.join(",")})`;
} else {
filterByFormula = `SEARCH("${escapedSearchTerm}", {*})`;
}

const response = await ctx.client["GET /v0/:baseId/:tableId"]({
...otherProps,
filterByFormula,
});

if ("hasPermission" in validationResult && !validationResult.hasPermission) {
return new Response(validationResult.message || "Access denied", {
status: 403,
});
}

const { searchTerm, searchFields, ...otherProps } = props;

let filterByFormula = "";
if (searchFields && searchFields.length > 0) {
const fieldFormulas = searchFields.map(
(field) => `SEARCH("${searchTerm}", {${field}})`,
);
filterByFormula = `OR(${fieldFormulas.join(",")})`;
} else {
filterByFormula = `SEARCH("${searchTerm}", {*})`;
if (!response.ok) {
return {
error: `Error searching records: ${response.statusText}`,
status: response.status,
};
}

const data = await response.json();
return {
data,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Short-circuit on permission errors.

validatePermissions can return a { error: string } result. Because we only branch on hasPermission, any validation error is ignored and the search executes with denied access. Add an error guard before the hasPermission check so we return a 403 on permission failures.

-    const validationResult = await ctx.invoke["airtable"].loaders.permissioning
-      .validatePermissions({
+    const validationResult = await ctx.invoke["airtable"].loaders.permissioning
+      .validatePermissions({
         mode: "check",
         baseId: props.baseId,
         tableIdOrName: props.tableId,
       });
 
+    if ("error" in validationResult && validationResult.error) {
+      return {
+        error: validationResult.error ?? "Access denied",
+        status: 403,
+      };
+    }
+
     if (
       "hasPermission" in validationResult && !validationResult.hasPermission
     ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const validationResult = await ctx.invoke["airtable"].loaders.permissioning
.validatePermissions({
mode: "check",
baseId: props.baseId,
tableIdOrName: props.tableId,
});
if (
"hasPermission" in validationResult && !validationResult.hasPermission
) {
return {
error: validationResult.message || "Access denied",
status: 403,
};
}
const { searchTerm, searchFields, ...otherProps } = props;
// Escape backslashes and double quotes in searchTerm to prevent formula injection
const escapedSearchTerm = searchTerm
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
let filterByFormula = "";
if (searchFields && searchFields.length > 0) {
const fieldFormulas = searchFields.map(
(field) => `SEARCH("${escapedSearchTerm}", {${field}})`,
);
filterByFormula = `OR(${fieldFormulas.join(",")})`;
} else {
filterByFormula = `SEARCH("${escapedSearchTerm}", {*})`;
}
const response = await ctx.client["GET /v0/:baseId/:tableId"]({
...otherProps,
filterByFormula,
});
if ("hasPermission" in validationResult && !validationResult.hasPermission) {
return new Response(validationResult.message || "Access denied", {
status: 403,
});
}
const { searchTerm, searchFields, ...otherProps } = props;
let filterByFormula = "";
if (searchFields && searchFields.length > 0) {
const fieldFormulas = searchFields.map(
(field) => `SEARCH("${searchTerm}", {${field}})`,
);
filterByFormula = `OR(${fieldFormulas.join(",")})`;
} else {
filterByFormula = `SEARCH("${searchTerm}", {*})`;
if (!response.ok) {
return {
error: `Error searching records: ${response.statusText}`,
status: response.status,
};
}
const data = await response.json();
return {
data,
};
try {
const validationResult = await ctx.invoke["airtable"].loaders.permissioning
.validatePermissions({
mode: "check",
baseId: props.baseId,
tableIdOrName: props.tableId,
});
if ("error" in validationResult && validationResult.error) {
return {
error: validationResult.error ?? "Access denied",
status: 403,
};
}
if (
"hasPermission" in validationResult && !validationResult.hasPermission
) {
return {
error: validationResult.message || "Access denied",
status: 403,
};
}
const { searchTerm, searchFields, ...otherProps } = props;
// Escape backslashes and double quotes in searchTerm to prevent formula injection
const escapedSearchTerm = searchTerm
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
let filterByFormula = "";
if (searchFields && searchFields.length > 0) {
const fieldFormulas = searchFields.map(
(field) => `SEARCH("${escapedSearchTerm}", {${field}})`,
);
filterByFormula = `OR(${fieldFormulas.join(",")})`;
} else {
filterByFormula = `SEARCH("${escapedSearchTerm}", {*})`;
}
const response = await ctx.client["GET /v0/:baseId/:tableId"]({
...otherProps,
filterByFormula,
});
if (!response.ok) {
return {
error: `Error searching records: ${response.statusText}`,
status: response.status,
};
}
const data = await response.json();
return {
data,
};
🤖 Prompt for AI Agents
In airtable/loaders/searchRecords.ts around lines 87 to 136, the
validatePermissions result can be an error object which is currently ignored;
add a guard that checks if ("error" in validationResult) and if so return {
error: validationResult.error || "Access denied", status: 403 } before the
existing hasPermission branch so permission validation failures short-circuit
and do not allow the search to proceed.

@JonasJesus42 JonasJesus42 marked this pull request as draft November 12, 2025 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant