Skip to content

Commit 1eced78

Browse files
ARHAEEMclaude
andcommitted
refactor(tools): move download_* to a new local-write category
download_formula_field and download_base_formulas READ from Airtable but WRITE .formula files to a caller-chosen path, so they did not belong in the `read` category -- the read-only profile is advertised as "Schema inspection, formula validation, and record reading only". Correcting the readOnlyHint annotation (previous commit) restored the MCP client's consent prompt, but the profile itself still claimed a file-writing tool was read-only. New `local-write` category, included in safe-write and full, so those tool sets are unchanged (54 / 72). Only read-only changes: 12 -> 10. Deliberately NOT added to LEGACY_CATEGORIES_DEFAULT_ON, per the invariant documented there -- a pre-existing on-disk `custom` profile must not silently widen to include a category it never opted into. Propagated through every mirror the sync guard checks: tool-config.js, tool-profile.ts, shared types, extension package.json (new mcp.categories.localWrite setting + enumDescriptions), webview store + Settings toggle + store test, CLAUDE.md, all three READMEs, skill templates and the three banner SVGs. Verified: check:tool-sync green at 10/54/72 with 17 labels, 1451 mcp-server + 421 extension + 88 webview tests pass, pnpm build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 30cd950 commit 1eced78

16 files changed

Lines changed: 60 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Typed message protocol between extension host and webview. Exports `ExtensionMes
5151
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.
5252

5353
### packages/mcp-server
54-
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`).
54+
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`).
5555

5656
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/`.
5757

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

268268
All under `airtableFormula.*`:
269269
- `mcp.autoConfigureOnInstall` — auto-write MCP config to detected IDEs on first launch
270-
- `mcp.toolProfile``read-only` (12 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom`
271-
- `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.
270+
- `mcp.toolProfile``read-only` (10 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom`
271+
- `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 13 default to on.
272272
- `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)
273273
- `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.
274274
- `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).

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
| **IDE Auto-Setup** | One-click MCP config for Cursor, Windsurf, Claude Desktop, Cline, Amp ||
7171
| **AI Skills** | Pre-built Airtable-specific rules and workflows for AI coding assistants ||
7272
| **Daemon + Tunnel** | Persistent background server; optional Cloudflare or ngrok remote access ||
73-
| **Tool Profiles** | `read-only` (12 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom` permission scopes ||
73+
| **Tool Profiles** | `read-only` (10 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom` permission scopes ||
7474
| **OS Keychain Auth** | Browser-based Airtable login with SSO/2FA — credentials in your OS keychain ||
7575

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

packages/extension/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@
531531
"custom"
532532
],
533533
"enumDescriptions": [
534-
"Schema inspection, formula validation, and record reading only (12 tools)",
534+
"Schema inspection, formula validation, and record reading only (10 tools)",
535535
"Read + record read/write + create/update tables, fields, views, sidebar sections, and record templates, no deletes, no form metadata (54 tools)",
536536
"All tools enabled including destructive ops, form metadata, extensions, and daemon control (72 tools)",
537537
"User-defined per-tool selection"
@@ -618,6 +618,11 @@
618618
"default": false,
619619
"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."
620620
},
621+
"airtableFormula.mcp.categories.localWrite": {
622+
"type": "boolean",
623+
"default": true,
624+
"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."
625+
},
621626
"airtableFormula.mcp.tools": {
622627
"type": "object",
623628
"default": {},

packages/extension/src/mcp/tool-profile.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type ExtraCategoryKey =
2929
| 'view-section' | 'view-section-destructive'
3030
| 'form-write'
3131
| 'record-read' | 'record-write' | 'record-destructive'
32-
| 'sync' | 'daemon';
32+
| 'sync' | 'daemon' | 'local-write';
3333
export const TOOL_CATEGORIES: Record<string, keyof ToolCategories | ExtraCategoryKey> = {
3434
// Read-only / inspection
3535
get_base_schema: 'read',
@@ -41,8 +41,8 @@ export const TOOL_CATEGORIES: Record<string, keyof ToolCategories | ExtraCategor
4141
validate_formula: 'read',
4242
list_view_sections: 'read',
4343
list_record_templates: 'read',
44-
download_formula_field: 'read',
45-
download_base_formulas: 'read',
44+
download_formula_field: 'local-write',
45+
download_base_formulas: 'local-write',
4646
// Record read (snapshot read via readQueries)
4747
query_records: 'record-read',
4848
// Table mutations (non-destructive)
@@ -138,6 +138,7 @@ export const CATEGORY_LABELS: Record<string, string> = {
138138
'record-destructive': 'Record Destructive',
139139
'sync': 'Sync',
140140
'daemon': 'Daemon Control',
141+
'local-write': 'Local File Write',
141142
};
142143

143144
interface ProfileDef {
@@ -149,15 +150,15 @@ interface ProfileDef {
149150
export const BUILTIN_PROFILES: Record<'read-only' | 'safe-write' | 'full', ProfileDef> = {
150151
'read-only': { description: 'Schema inspection, formula validation, and record reading only', categories: ['read', 'record-read'] },
151152
'safe-write':{ description: 'Read + record read/write + create/update tables, fields, views, sidebar sections, and record templates (no deletes, no form metadata)',
152-
categories: ['read', 'record-read', 'record-write', 'table-write', 'field-write', 'view-write', 'view-section'] },
153+
categories: ['read', 'record-read', 'record-write', 'table-write', 'field-write', 'view-write', 'view-section', 'local-write'] },
153154
full: { description: 'All tools enabled including destructive ops, form metadata, extensions, and daemon control',
154155
categories: [
155156
'read', 'record-read', 'record-write', 'record-destructive',
156157
'table-write', 'table-destructive',
157158
'field-write', 'field-destructive',
158159
'view-write', 'view-destructive',
159160
'view-section', 'view-section-destructive',
160-
'form-write', 'extension', 'sync', 'daemon',
161+
'form-write', 'extension', 'sync', 'daemon', 'local-write',
161162
] },
162163
};
163164

@@ -183,6 +184,7 @@ export const SETTINGS_TO_CATEGORY: Record<keyof ToolCategories, string> = {
183184
recordWrite: 'record-write',
184185
sync: 'sync',
185186
daemon: 'daemon',
187+
localWrite: 'local-write',
186188
};
187189

188190
// Inverse: file-format category key → settings key suffix
@@ -288,6 +290,7 @@ export class ToolProfileManager implements vscode.Disposable {
288290
recordWrite: cfg.get('mcp.categories.recordWrite', true),
289291
sync: cfg.get('mcp.categories.sync', false),
290292
daemon: cfg.get('mcp.categories.daemon', false),
293+
localWrite: cfg.get('mcp.categories.localWrite', true),
291294
};
292295
return {
293296
profile,
@@ -345,6 +348,7 @@ export class ToolProfileManager implements vscode.Disposable {
345348
'record-write', 'record-destructive',
346349
'sync',
347350
'daemon',
351+
'local-write',
348352
];
349353
for (const cat of categoryOrder) {
350354
const label = CATEGORY_LABELS[cat] ?? cat;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ When user wants to convert an Excel formula to Airtable.
304304
export const MCP_TOOLS_GUIDE = `# Airtable MCP — Tools Guide
305305
306306
> **Server**: airtable-user-mcp v2.4.x | **Protocol**: MCP (JSON-RPC 2.0)
307-
> **Tools**: 72 tools across 16 categories + \`manage_tools\`
307+
> **Tools**: 72 tools across 17 categories + \`manage_tools\`
308308
309309
---
310310
@@ -771,7 +771,7 @@ and use airtable-user-mcp \`query_records\` to read/search data (especially when
771771
772772
- **Name**: airtable-user-mcp | **Version**: 2.4.x
773773
- **Protocol**: Model Context Protocol (JSON-RPC 2.0)
774-
- **Tools**: 72 tools across 16 categories + \`manage_tools\`
774+
- **Tools**: 72 tools across 17 categories + \`manage_tools\`
775775
- **Auth**: browser session (or PAT via Official MCP panel in the VS Code extension)
776776
777777
## Mandatory Workflows

packages/mcp-server/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## [Unreleased]
44

5+
### Changed (2026-08-01 — new `local-write` category; read-only is 12 → 10 tools)
6+
7+
- **`download_formula_field` and `download_base_formulas` moved out of `read` into a new
8+
`local-write` category.** They READ from Airtable but WRITE .formula files to a path the caller
9+
chooses, so a profile advertised as "Schema inspection, formula validation, and record reading
10+
only" had no business containing them — the annotation fix in the previous entry restored the
11+
client's consent prompt, but the profile itself was still mislabelled. `local-write` is included
12+
in **safe-write** and **full**, so those tool sets are UNCHANGED (54 / 72); only **read-only**
13+
changes, 12 → 10. Deliberately NOT added to `LEGACY_CATEGORIES_DEFAULT_ON`, so a pre-existing
14+
`custom` profile does not silently widen to include it. New setting:
15+
`airtableFormula.mcp.categories.localWrite` (default true).
16+
517
### Fixed (2026-07-31 — sync prune could delete data it had not replaced)
618

719
- **`pruneRecords` had only a RUN-wide failure gate.** The "don't prune after a failed run" check

packages/mcp-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ The official Airtable MCP is a thin wrapper over the public Web API. That API
111111
| Form metadata (description, redirect, attribution, branding) |||
112112
| Extension & dashboard page management || ✅ install, enable, rename, duplicate, remove |
113113
| Daemon self-diagnosis (is the session dead, the browser busy, or the daemon gone?) ||`manage_daemon` `action=status` — plus start / restart / stop / tunnel / token rotation |
114-
| Tool profiles & per-tool toggles ||`read-only` (12 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom` |
114+
| Tool profiles & per-tool toggles ||`read-only` (10 tools) / `safe-write` (54 tools) / `full` (72 tools) / `custom` |
115115
| Install effort | Manual PAT + JSON edit per client | Single `claude mcp add` or JSON snippet |
116116
| Price | Free | Free, MIT |
117117

packages/mcp-server/assets/banner-dark.svg

Lines changed: 1 addition & 1 deletion
Loading

packages/mcp-server/assets/banner-light.svg

Lines changed: 1 addition & 1 deletion
Loading

packages/mcp-server/assets/banner.svg

Lines changed: 1 addition & 1 deletion
Loading

0 commit comments

Comments
 (0)