sheets-mcp docs
Concepts

Database Schema

Every table, index, and Postgres role in sheets-mcp. Source: SQL migration files and route.ts.

sheets-mcp uses Supabase as a Postgres host only — not Supabase Auth. All authentication is handled by better-auth. Two separate pg.Pool instances exist in the codebase (one in lib/db.ts for MCP tools, one inside lib/auth.ts for better-auth) — consolidating them is a known roadmap item.


Migration files

FileApplies
better-auth_migrations/2026-04-13T03-22-30.752Z.sqlCore auth tables (user, session, account, verification, oauthApplication, oauthAccessToken, apikey)
better-auth_migrations/2026-04-13T06-41-17.610Z.sqlAdds sheet_snapshots table
scripts/slice1_write_history.sqlAdds write_history table + prune_write_history() function
scripts/slice3_gin_index.sqlGIN index on sheet_snapshots.data
scripts/slice4_role_isolation.sqlCreates sheet_analyzer Postgres role

All scripts are idempotent (IF NOT EXISTS). Safe to run multiple times.


Tables

user

Standard better-auth user table.

ColumnTypeNotes
idtext PK
nametext
emailtext unique
emailVerifiedboolean
imagetextnullable
createdAttimestamptz
updatedAttimestamptz

account

Stores OAuth tokens per provider per user. The Google OAuth token that sheets-mcp uses for all Sheets/Drive API calls lives here.

ColumnTypeNotes
idtext PK
accountIdtextGoogle sub (user ID)
providerIdtext"google"
userIdtext FK → user.idcascade delete
accessTokentextCurrent Google access token
refreshTokentextUsed for proactive refresh
accessTokenExpiresAttimestamptzRefresh triggered if within 5 min
scopetextSpace-separated OAuth scopes

Warning: All column names in this table are camelCase and must be double-quoted in raw SQL queries. lib/mcp-google.ts uses "accessToken", "refreshToken", "accessTokenExpiresAt", "userId" — without quotes, Postgres silently folds to lowercase and returns no rows.


session

better-auth session tokens (used for browser cookie sessions).

ColumnType
idtext PK
expiresAttimestamptz
tokentext unique
userIdtext FK → user.id
ipAddresstext
userAgenttext

oauthApplication

OIDC client registrations. Claude Desktop and Cursor each get a row here when they first complete the OAuth handshake.

ColumnType
idtext PK
clientIdtext unique
clientSecrettext
redirectUrlstext
typetext

oauthAccessToken

JWTs issued to MCP clients (Claude, Cursor) after OIDC consent. These are what withMcpAuth verifies on every tool call.

Note

withMcpAuth intercepts the Bearer JWT and passes the decoded OAuthAccessToken as the session object. It does not call auth.api.getSession() (which relies on cookies that headless MCP clients lack). The userId is mapped manually from session.userId || session.user?.id.


apikey

API keys created from the dashboard. The apiKey plugin uses enableSessionForAPIKeys: true — a valid x-api-key header resolves to the same user context as an OIDC JWT.


sheet_snapshots

Dual-purpose cache table. Used for two distinct cache types identified by range_key:

range_key valueCache typeTTLSet byRead by
'__schema__'Sheet schema (frozen rows, protected ranges, ARRAYFORMULA columns)300 secondsgetSheetSchema()All write tools, read_range
A1 range string (e.g. Sheet1!A:F)Range data (JSONB objects)60 secondsanalyze_rangeanalyze_range only

Key columns:

ColumnTypeNotes
user_idtext
spreadsheet_idtext
range_keytext'__schema__' or normalized A1
row_countinteger0 for schema cache
datajsonbSchema object or JSONB row array
expires_attimestamptzChecked on every read (AND expires_at > now())
cached_attimestamptzUpdated on upsert

Unique constraint: (user_id, spreadsheet_id, range_key) — uses ON CONFLICT ... DO UPDATE for upserts.

GIN index on data column (jsonb_path_ops) — from slice3_gin_index.sql. Accelerates JSONB key lookups inside the SQL execution path.

Invalidation: DELETE FROM sheet_snapshots WHERE user_id = $1 AND spreadsheet_id = $2 — runs after every successful write (write_range, transform_range, clear_range, manage_sheets). Clears both schema and range caches for that spreadsheet.


write_history

Pre-write snapshots. Populated before every write_range and transform_range call.

ColumnTypeNotes
iduuid PKgen_random_uuid()
user_idtext
spreadsheet_idtext
range_keytextNormalized A1 range
tool_nametext'write_range' or 'transform_range'
before_valuesjsonb2D array of cell values before write
after_summaryjsonb{ updatedRows, columns } audit summary (nullable)
created_attimestamptz

Index: (user_id, spreadsheet_id, created_at DESC) — for fast snapshot listing per spreadsheet.

Retention: prune_write_history(user_id, spreadsheet_id) is called after every insert. Deletes all rows beyond the most recent 50 per user_id + spreadsheet_id.

Snapshot size guard: Snapshot is only saved if rows ≤ 1,000 AND JSON.stringify(values).length ≤ 2MB. Larger ranges skip the snapshot silently.


Postgres role: sheet_analyzer

Created by scripts/slice4_role_isolation.sql. Used exclusively by analyze_range and transform_range via SET LOCAL ROLE sheet_analyzer.

Permissions:

  • GRANT USAGE ON SCHEMA public — allows using Postgres built-in functions
  • All other privileges explicitly revoked
  • Cannot read: account, user, session, apikey, sheet_snapshots, write_history

This role is the final defense layer: even if the regex SQL injection guard is bypassed, the query runs as a role with no access to any application data.

-- Applied in every analyze_range + transform_range call:
BEGIN READ ONLY;
SET LOCAL ROLE sheet_analyzer;
-- user SQL executes here
ROLLBACK;

Warning: If sheet_analyzer role does not exist, all analyze_range and transform_range calls fail with role "sheet_analyzer" does not exist. Run scripts/slice4_role_isolation.sql and GRANT sheet_analyzer TO CURRENT_USER before first deployment.

On this page