Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,91 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how

## [Unreleased]

### Fixed — PR #22 acceptance audit follow-ups (2026-08-01)

- **Existing custom profiles stay closed when a category is added.** The MCP server already
treated absent tools in post-legacy categories as disabled, but extension startup rewrote the
same file from VS Code's package defaults first — silently enabling `local-write`. Startup and
file-to-settings sync now preserve the server's fail-closed semantics, including mixed per-tool
states, and a later category change clears those imported overrides before applying the user's
explicit choice — from the dashboard toggle or a native settings.json / Settings UI edit alike
(the imported overrides previously won over a native edit silently, in both the written file and
the effective tool set). The dashboard toggle also always persists its result and can no longer
latch settings→file sync off for the session when it races the file watcher's suppression window.
- **Record-prune failure accounting now follows destination table IDs.** Per-table write results
were stored under the source table name but read back under the destination table name, so a
renamed matched table could lose the all-writes-failed no-prune guard. A regression test runs the
write and prune phases together with differing names.
- **Daemon exit intents have request ownership.** An older concurrent `/mcp` response could finish
after `manage_daemon stop` staged its process-wide intent, steal it, and begin shutdown before the
stop confirmation flushed. `AsyncLocalStorage` now binds the intent to the response that staged
it, with a deterministic two-request regression test. A second stop/restart arriving while one is
already staged is refused (naming the staged action) instead of silently overwriting it, and an
intent whose staging response died before flushing is discarded — so a flushed "stopping"
confirmation is always followed by the exit and the single slot can never be orphaned.
- **Session restore no longer bundles vulnerable `adm-zip` 0.5.x.** A tiny crafted archive could
declare a multi-gigabyte uncompressed entry and crash the extension host before CRC validation.
The bundled parser is now 0.6.0, the restore picker size-checks before reading only the encryption
header, and declared uncompressed totals are rejected before entry allocation.
- **The shipped Ajv URI parser is patched without widening the dependency refresh.** The lockfile
moves `fast-uri` 3.1.0 → 3.1.5 through the existing MCP SDK dependency, clearing its four
production audit findings while leaving the SDK and browser-auth stack pinned.
- Corrected the live tool references to list 9 read tools and 2 local-file-write tools, completed
the dashboard's early-activation category fallback, and removed stale secret-bearing login CLI
examples and an inapplicable `login.json` hint.

### Fixed — formula diagnostics were quadratic, blocking the editor on every keystroke (2026-07-31)

- **`isInsideExclusionRange` was a linear scan run once per character.** `ranges.some(...)` is
O(field refs) and is called per character by `checkParentheses`, `checkQuotes` and
`checkBrackets`, plus once per match by five more checkers — so cost was chars × refs, with no
debounce and no size cap on either entry point (`registration.ts`'s `onDidChangeTextDocument`
and the LSP's `onDidChangeContent`, which in `--tcp` daemon mode is shared by every attached
editor). `getFieldRefRanges` emits ascending, non-overlapping spans, so this is now a binary
search. Measured on this repo's own largest shipped example
(`examples/[IGD-JSON]~[Payload]~[Formula].formula`, 38,830 chars / 741 refs):
**83.3 ms → 9.6 ms** per run. On an 87 KB / 4,000-ref synthetic: **592 ms → 10 ms**. Diagnostic
output is byte-identical before and after.

### Fixed — Unconfigure destroyed unrelated config in Codex / Helix files (2026-07-31)

- **`unconfigureMcpToml` and `unconfigureHelix` truncated the user's config file from our marker
to EOF.** Both did `existing.slice(0, indexOf(MARKER))`, discarding everything below our block
rather than removing only our block. Since `configureMcpToml` appends at EOF and `codex mcp add`
does too, any second MCP server or `[model_providers.*]` section the user added after running
Setup sat below ours — and clicking **Unconfigure** in the dashboard (no confirmation prompt)
destroyed it silently, with no backup and no error. Measured against the old code, a
`config.toml` holding one extra MCP server was reduced to a **single newline**.
Both now remove only the sections we own, preserving everything else verbatim.
Note `HELIX_BLOCK` is *four* top-level tables, not one, so a naive "delete to the next `[`
header" would have orphaned three `[[language]]` blocks; the header matcher is also strict
enough not to mistake a continuation line of a multi-line array (`matrix = [\n[1,2],\n]`) for a
table header. Pinned by `src/test/lsp-config-toml.test.ts`.

### Fixed — formatter tokenizers silently corrupted formulas / hung the extension host (2026-07-31)

Found by audit, both reproduced before fixing.

- **The default (v2) beautifier and minifier deleted lowercase function names and wrote the
result to disk.** Airtable function names are case-insensitive, but both v2 tokenizers matched
`/[A-Z_]/` with **no `/i` flag**, so every `a`–`z` character fell through to the operator
chain's `else { i++ }` catch-all and vanished from the token stream. Measured before the fix:
`lower({Email})` → `({Email})`, `IF({A}, lower({B}), 0)` → `IF({A}, ({B}), 0)`,
`if({A},1,0)` →(minify)→ `({A},1,0)`, `If({A},1,0)` →(minify)→ `I({A},1,0)`. Because the
mangled output still *parses*, `beautify()`'s try/catch never fired and no diagnostic was
raised — so Shift+Alt+F and `editor.formatOnSave` corrupted the buffer silently, and the bulk
`beautifyFilesWithStyle` path wrote unopened files directly with no undo stack. Both
tokenizers now match case-insensitively and canonicalise only for the `FUNCTIONS`/`CONSTANTS`
lookup, so **the casing you typed is preserved** in the output.
- **The v1 beautifier and minifier hung the extension host on an unrecognized character.** The
identifier fallback was the only branch of the tokenizer loop with no unconditional advance:
a character matched by no branch (`;`, `%`, `[`, `}`, a smart quote pasted from a doc, any
non-ASCII letter) matched zero characters, left the cursor unmoved, and spun the loop
allocating until V8 aborted (`Ineffective mark-compacts`, ~2 s). Both now **throw** rather
than skip, which engages the callers' existing try/catch — an untokenizable formula is
returned unchanged instead of being emitted with characters deleted.
- Pinned by `packages/extension/src/test/formatter-tokenizer.test.ts` (21 cases).

### Added — `manage_daemon` tool and the `Daemon Control` category (2026-07-30)

- **New MCP tool `manage_daemon` in a new `daemon` category.** Tool counts move
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Typed message protocol between extension host and webview. Exports `ExtensionMes
React 19 + Vite 6 + Tailwind CSS v4 + Zustand 5 dashboard. Three tabs: Overview, Setup, Settings. Builds directly into `packages/extension/dist/webview/`. Communicates with the extension host via `acquireVsCodeApi().postMessage()` — messages are typed through the shared package.

### packages/mcp-server
The Airtable MCP server itself — ES modules Node app, **published to npm as `airtable-user-mcp`**. Provides **72 tools** across 16 categories (read, record-read, table-write, table-destructive, field-write, field-destructive, view-write, view-destructive, view-section, view-section-destructive, form-write, extension, record-write, record-destructive, sync, daemon) via `@modelcontextprotocol/sdk`. Includes `upload_attachment` (record-write) which writes attachment cells by URL — the general `update_records` tool cannot set attachment cells. The `daemon` category holds exactly one tool, `manage_daemon` (`src/daemon/manage.js`) — the only way the model can see its own runtime: `action=status` reports daemon liveness/holder/transport/uptime/tunnel plus the live session state (`sessionDead`, the last breaker trip including Airtable's captured response body, the browser busy queue), which is what distinguishes *daemon gone* from *session dead* from *browser busy*. `start`/`restart`/`stop`/`tunnel_*`/`token_rotate` administer the process; `stop`/`restart` answer first and exit from a `res.on('finish')` hook in `daemon/server.js` (exiting from the handler deadlocks the SDK's `enableJsonResponse` promise), `stop` writes a `daemon.stopped` sentinel so the extension does not silently respawn, and `token_rotate`/`tunnel_*` are refused for tunnel-origin callers. `daemon` is **`full`-profile only** and defaults off for pre-existing `custom` profiles. Uses `patchright` (Chromium stealth fork) with a persistent profile for browser-based authentication against Airtable's internal API. **Transport:** API calls run through a direct node HTTP transport (`src/http-transport.js`; `fetch` default, `impit` Chrome-TLS-impersonation via `AIRTABLE_HTTP_CLIENT=impit`) — NOT through the browser page; the browser mints/refreshes the session cookie only. Proxy env (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`) is honored by the `fetch` client via undici's `EnvHttpProxyAgent` (`src/proxy.js`; `undici` is an optionalDependency — absent → direct, one warning); the `impit` client is NOT proxy-aware, and TLS-inspecting proxies additionally need `NODE_EXTRA_CA_CERTS` (Node ignores the OS trust store). Auth is cookie-only (no bearer/API key). `AIRTABLE_AUTH_MODE`: `browser` (default) | `byo` (cookie-only, no browser — `AIRTABLE_COOKIE` or `~/.airtable-user-mcp/credentials.json`, csrf auto-scraped; `src/byo-credentials.js`) | `direct-login` (browser-free login via impit + otpauth TOTP replaying the HTTP login flow; `src/direct-login.js`).
The Airtable MCP server itself — ES modules Node app, **published to npm as `airtable-user-mcp`**. Provides **72 tools** across 17 categories (read, record-read, table-write, table-destructive, field-write, field-destructive, view-write, view-destructive, view-section, view-section-destructive, form-write, extension, record-write, record-destructive, sync, daemon, local-write) via `@modelcontextprotocol/sdk`. Includes `upload_attachment` (record-write) which writes attachment cells by URL — the general `update_records` tool cannot set attachment cells. The `daemon` category holds exactly one tool, `manage_daemon` (`src/daemon/manage.js`) — the only way the model can see its own runtime: `action=status` reports daemon liveness/holder/transport/uptime/tunnel plus the live session state (`sessionDead`, the last breaker trip including Airtable's captured response body, the browser busy queue), which is what distinguishes *daemon gone* from *session dead* from *browser busy*. `start`/`restart`/`stop`/`tunnel_*`/`token_rotate` administer the process; `stop`/`restart` answer first and exit from a `res.on('finish')` hook in `daemon/server.js` (exiting from the handler deadlocks the SDK's `enableJsonResponse` promise), `stop` writes a `daemon.stopped` sentinel so the extension does not silently respawn, and `token_rotate`/`tunnel_*` are refused for tunnel-origin callers. `daemon` is **`full`-profile only** and defaults off for pre-existing `custom` profiles. Uses `patchright` (Chromium stealth fork) with a persistent profile for browser-based authentication against Airtable's internal API. **Transport:** API calls run through a direct node HTTP transport (`src/http-transport.js`; `fetch` default, `impit` Chrome-TLS-impersonation via `AIRTABLE_HTTP_CLIENT=impit`) — NOT through the browser page; the browser mints/refreshes the session cookie only. Proxy env (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`) is honored by the `fetch` client via undici's `EnvHttpProxyAgent` (`src/proxy.js`; `undici` is an optionalDependency — absent → direct, one warning); the `impit` client is NOT proxy-aware, and TLS-inspecting proxies additionally need `NODE_EXTRA_CA_CERTS` (Node ignores the OS trust store). Auth is cookie-only (no bearer/API key). `AIRTABLE_AUTH_MODE`: `browser` (default) | `byo` (cookie-only, no browser — `AIRTABLE_COOKIE` or `~/.airtable-user-mcp/credentials.json`, csrf auto-scraped; `src/byo-credentials.js`) | `direct-login` (browser-free login via impit + otpauth TOTP replaying the HTTP login flow; `src/direct-login.js`).

Standalone users install via `npx airtable-user-mcp` or `npm i -g airtable-user-mcp`. The CLI exposes subcommands: `login`, `logout`, `status`, `doctor`, `install-browser`, `daemon start/stop/status`. Config and session data live in `~/.airtable-user-mcp/`.

Expand Down Expand Up @@ -267,8 +267,8 @@ Located at `packages/mcp-server/dev-tools/` (gitignored):

All under `airtableFormula.*`:
- `mcp.autoConfigureOnInstall` — auto-write MCP config to detected IDEs on first launch
- `mcp.toolProfile` — `read-only` (12 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom`
- `mcp.categories.{read,recordRead,recordWrite,recordDestructive,tableWrite,tableDestructive,fieldWrite,fieldDestructive,viewWrite,viewDestructive,viewSection,viewSectionDestructive,formWrite,extension,sync,daemon}` — per-category toggles when profile is `custom`. `sync`, `recordDestructive` and `daemon` default to **off**; the other 13 default to on.
- `mcp.toolProfile` — `read-only` (10 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom`
- `mcp.categories.{read,recordRead,recordWrite,recordDestructive,tableWrite,tableDestructive,fieldWrite,fieldDestructive,viewWrite,viewDestructive,viewSection,viewSectionDestructive,formWrite,extension,sync,daemon,localWrite}` — per-category toggles when profile is `custom`. `sync`, `recordDestructive` and `daemon` default to **off**; the other 14 default to on.
- `mcp.daemonPort` — fixed TCP port for the shared MCP daemon HTTP server (default 8723, kept stable across restarts; 0 = automatic/ephemeral; falls back to an automatic port if the chosen port is busy; takes effect on next daemon restart)
- `mcp.authMode` — `browser` (default; headless Chrome mints the cookie, calls go direct-HTTP) / `byo` (cookie-only, no browser; cookie from `~/.airtable-user-mcp/credentials.json` or `AIRTABLE_COOKIE`) / `direct-login` (browser-free email+password+TOTP; `login.json` or `AIRTABLE_EMAIL/PASSWORD/TOTP_SECRET`). Injected as `AIRTABLE_AUTH_MODE` into the spawned server via `buildDaemonEnv`/`registration.ts` (non-default only). Takes effect on next daemon/server restart.
- `mcp.httpClient` — `fetch` (default) / `impit` (Chrome-TLS impersonation fallback). Injected as `AIRTABLE_HTTP_CLIENT`. `impit` must be available to the server (optionalDependency; add to the vendored deps for the bundled path).
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
| **IDE Auto-Setup** | One-click MCP config for Cursor, Windsurf, Claude Desktop, Cline, Amp | — |
| **AI Skills** | Pre-built Airtable-specific rules and workflows for AI coding assistants | — |
| **Daemon + Tunnel** | Persistent background server; optional Cloudflare or ngrok remote access | — |
| **Tool Profiles** | `read-only` (12 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom` permission scopes | — |
| **Tool Profiles** | `read-only` (10 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom` permission scopes | — |
| **OS Keychain Auth** | Browser-based Airtable login with SSO/2FA — credentials in your OS keychain | — |

---
Expand Down Expand Up @@ -127,7 +127,7 @@ This is a coverage map, not a "pick one" decision — the two servers are comple
| **Extension / block management (install, enable, rename, duplicate, remove)** | ❌ | ✅ |
| **Create dashboard pages** | ❌ | ✅ |
| **Daemon self-diagnosis (session dead? browser busy? daemon gone?)** | ❌ | ✅ `manage_daemon` `action=status`, plus start / restart / stop / tunnel / token rotation |
| **Tool profiles & per-tool toggles** | ❌ | ✅ read-only (12) / safe-write (54) / full (72) / custom |
| **Tool profiles & per-tool toggles** | ❌ | ✅ read-only (10) / safe-write (54) / full (72) / custom |
| **Destructive-action safety guards** | Relies on token scopes | ✅ `expectedName` match, dependency summary, `force` flag |
| **Batch record create limit** | 10 / request | Uses the same Airtable limit; no added restriction |
| **VS Code / Cursor / Windsurf / Cline / Amp one-click install** | Manual JSON edit per IDE | ✅ One click via the companion extension |
Expand Down Expand Up @@ -198,7 +198,8 @@ Manage Airtable bases with capabilities **not available through the official RES

| Category | Tools | Highlights |
|:---------|:-----:|:-----------|
| **Schema Read** | 11 | Full schema inspection — bases, tables, fields, views, sidebar sections, record templates; download all formula fields to local files |
| **Schema Read** | 9 | Full schema inspection — bases, tables, fields, views, sidebar sections, record templates |
| **Local File Write** | 2 | Download one formula field or every formula in a base to caller-chosen `.formula` files; excluded from `read-only` |
| **Record Read** | 1 | `query_records` — up to 1 000 records/call with resolved field values; `search` param works on lookup/rollup fields (REST API `filterByFormula` doesn't) |
| **Record Write** | 4 | `create_records` / `update_records` / `duplicate_records` / `upload_attachment` (the only way to write `multipleAttachments` cells by URL) |
| **Record Destructive** | 1 | `delete_records` — batch-delete records from a table |
Expand Down
11 changes: 8 additions & 3 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@
"custom"
],
"enumDescriptions": [
"Schema inspection, formula validation, and record reading only (12 tools)",
"Schema inspection, formula validation, and record reading only (10 tools)",
"Read + record read/write + create/update tables, fields, views, sidebar sections, and record templates, no deletes, no form metadata (54 tools)",
"All tools enabled including destructive ops, form metadata, extensions, and daemon control (72 tools)",
"User-defined per-tool selection"
Expand All @@ -541,7 +541,7 @@
"airtableFormula.mcp.categories.read": {
"type": "boolean",
"default": true,
"description": "Read / Inspect tools: get_base_schema, list_tables, get_table_schema, list_fields, list_views, get_view, validate_formula, list_view_sections."
"description": "Read / Inspect tools: get_base_schema, list_tables, get_table_schema, list_fields, list_views, get_view, validate_formula, list_view_sections, list_record_templates."
},
"airtableFormula.mcp.categories.tableWrite": {
"type": "boolean",
Expand Down Expand Up @@ -618,6 +618,11 @@
"default": false,
"description": "Daemon Control tool: manage_daemon (inspect and administer the MCP daemon itself — not Airtable). action=status is read-only and reports daemon liveness, transport, uptime, tunnel URL and the live session/browser state; start, restart, stop, tunnel_enable, tunnel_disable and token_rotate control the running process. Off by default: this lets an AI agent stop or restart the server it is talking through."
},
"airtableFormula.mcp.categories.localWrite": {
"type": "boolean",
"default": true,
"description": "Local File Write tools: download_formula_field, download_base_formulas. These READ from Airtable but WRITE .formula files to a path the caller chooses, so they are not part of the read-only profile. Included in safe-write and full."
},
"airtableFormula.mcp.tools": {
"type": "object",
"default": {},
Expand Down Expand Up @@ -755,7 +760,7 @@
"@types/vscode": "^1.100.0",
"@vscode/test-electron": "^2.4.0",
"@vscode/vsce": "^3.0.0",
"adm-zip": "^0.5.17",
"adm-zip": "^0.6.0",
"airtable-user-mcp": "workspace:*",
"archiver": "^7.0.1",
"tsup": "^8.0.0",
Expand Down
Loading
Loading