pg-migration-verify
Verifies whether Postgres migrations were actually applied by parsing migration SQL, determining the expected tables, columns, indexes, policies, and other objects, and comparing them against the live PostgreSQL catalog. Provides read-only migration verification and diffing tools.
Checks whether Supabase/Postgres migrations are truly applied by comparing repository migration files with the live database catalog and the Supabase migration history table. Detects migrations applied but not recorded, recorded but mismatched, and discrepancies between the repo and history.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
pg-migration-verify
An MCP server that tells your coding agent whether a Postgres/Supabase migration was really applied, by checking every object it should create against pg_catalog. Read-only by construction.
Why
On many Supabase projects, supabase_migrations.schema_migrations does not match the repository. Migrations pasted into the Supabase SQL editor run, but they never land in the history table; a migration recorded by the CLI can still have been edited by hand afterwards. So the history table cannot answer "is migration X applied?". Answering it by hand means writing one catalog query per table, column, index and policy, then counting until you get MATCH 73/73. This server does that counting for Claude Code and any other MCP client: it parses each migration with the real Postgres parser, works out what should exist today, and compares that with the live catalog.
Demo recording coming soon.
Related MCP server: Lintel
Contents
30-second quickstart
Requires Node.js 22 or later. The server runs with npx, so there is nothing else to install. Create a read-only role first; any postgres:// or postgresql:// URL works, but a read-only role is strongly recommended.
Claude Code: plugin (recommended)
The repository is its own plugin marketplace. In Claude Code:
/plugin marketplace add ColeUD/pg-migration-verify
/plugin install pg-migration-verify@pg-migration-verifyThe plugin has one option, database_url: a connection string like the ones below. It is marked sensitive, so Claude Code keeps it in its credential store rather than in settings.json. Claude Code may ask for it during the install; to set it (or change it later), run in Claude Code:
/plugin configure pg-migration-verify@pg-migration-verifyFrom a shell, claude plugin configure only lists the options unless it gets --values-stdin. To save the value from a script, put {"database_url": "postgresql://..."} in a file outside the repository and pipe it in, then delete the file:
claude plugin configure pg-migration-verify@pg-migration-verify --values-stdin < pgmv-config.jsonReading from a file keeps the password out of your shell history and the process list, which echo '{...}' | or --config database_url=... would not.
The plugin passes the option to the server as PGMV_DATABASE_URL and points PGMV_PROJECT_DIR at the current project, so migrations are read from <project>/supabase/migrations. Leave the option empty and the server falls back to DATABASE_URL from the environment Claude Code was started in.
Then run the bundled skill, optionally with a migration file name, version or name:
/pg-migration-verify:verify-migrations
/pg-migration-verify:verify-migrations 20240305120000Or just ask: "Was the create_tasks migration really applied?" The server's instructions steer the model to verify_migration / verify_all rather than the history table.
Claude Code: plain MCP server
Without the plugin (no skill, same tools):
claude mcp add --env "PGMV_DATABASE_URL=postgresql://pgmv_readonly:<password>@<host>:5432/postgres?sslmode=verify-full" \
--scope local --transport stdio pgmv -- npx -y pg-migration-verify@0.1.0Keep sslmode in the URL: without it the connection is not encrypted (see TLS; for Supabase also add sslrootcert as in the Desktop example below). Keep another option between --env and the server name pgmv; otherwise claude mcp add reads pgmv as a second environment variable. Migrations are looked up in supabase/migrations under the server's working directory. If list_repo_migrations shows the wrong directory, add --env PGMV_PROJECT_DIR=/path/to/your/app.
Tool names, for permission rules: mcp__plugin_pg-migration-verify_pgmv__<tool> when installed as a plugin, mcp__pgmv__<tool> when added with claude mcp add.
Claude Desktop
Add the server to claude_desktop_config.json (Settings, Developer, Edit Config; on Windows the file is %APPDATA%\Claude\claude_desktop_config.json, on macOS ~/Library/Application Support/Claude/claude_desktop_config.json), then quit and restart Desktop:
{
"mcpServers": {
"pgmv": {
"command": "npx",
"args": ["-y", "pg-migration-verify@0.1.0"],
"env": {
"PGMV_DATABASE_URL": "postgresql://pgmv_readonly:<password>@db.<project-ref>.supabase.co:5432/postgres?sslmode=verify-full&sslrootcert=/absolute/path/to/supabase-ca.crt",
"PGMV_PROJECT_DIR": "/absolute/path/to/your/app"
}
}
}
}Desktop has no project directory and does not expand ${VAR} references, so both values are written literally. This file then holds the password in plain text, which is one more reason to use a read-only role.
If the server fails to start on Windows, try wrapping npx in cmd: "command": "cmd" and "args": ["/c", "npx", "-y", "pg-migration-verify@0.1.0"]. This is a commonly reported workaround that has not been verified for this package. The server's log is %APPDATA%\Claude\logs\mcp-server-pgmv.log.
Tools
Every tool is read-only and annotated readOnlyHint: true. Each returns readable text plus the same facts as structuredContent (with an outputSchema), so an agent can branch on a verdict without parsing text.
Tool | What it answers | Needs DB? |
| Which migrations are really applied? Summary table for every file, the full report of each MISMATCH, and totals that flag "applied but NOT recorded". | Yes |
| Is this one migration applied? | Yes |
| Do the files and | Yes |
| What did the Supabase CLI record as applied? Reports a missing or unreadable history table as a result, not an error. | Yes |
| What should this migration leave in the database? Every expected object with its line, plus statements that cannot be checked. | No |
| Which migration files exist? In apply order, with sha256, misnamed | No |
Arguments: dir (optional, every tool except list_recorded_migrations) overrides the migrations directory, relative to the project directory; it must lie inside the project directory or the configured migrations directory. file (verify_migration, expected_objects) accepts the file name, a path ending in it, the version or the name; only exact matches count. verbose (verify_migration, verify_all) also lists matching objects and ignored statements.
Example output
verify_all against a database whose history records 4 of 8 migrations and which was then edited by hand. This text is produced by an integration test against a real Postgres with synthetic migrations, and CI fails if it drifts from docs/example-output.txt.
VERSION NAME RESULT MATCHED SUPERSEDED UNVERIFIABLE HISTORY
20240301090000 create_organizations MATCH 23/23 0 0 recorded
20240301091500 create_profiles_and_memberships MATCH 20/20 1 0 recorded
20240302100000 create_projects MATCH 18/18 0 0 NOT RECORDED
20240305120000 create_tasks MISMATCH 21/23 1 0 recorded
20240310083000 create_comments MISMATCH 12/14 9 2 NOT RECORDED
20240315140000 task_details_and_search MISMATCH 5/6 0 1 recorded
20240320090000 rework_profiles_and_tasks MATCH 6/6 0 0 NOT RECORDED
20240322100000 seed_plans NOTHING TO VERIFY - 0 2 NOT RECORDED
8 migrations: 4 MATCH, 3 MISMATCH, 1 nothing to verify. 2 applied but NOT recorded in history.
2 recorded in history but MISMATCH: 20240305120000_create_tasks.sql, 20240315140000_task_details_and_search.sql
Skipped 1 .sql file not named <version>_<name>.sql (never applied by the Supabase CLI): add_billing_draft.sql
MISMATCH 21/23 20240305120000_create_tasks.sql
MISSING line 5 column public.tasks.due_on (date, nullable)
MISSING line 18 index public.tasks_project_status_idx (on public.tasks (project_id, status), using btree)
SUPERSEDED line 19 index public.tasks_assignee_idx: dropped by 20240320090000_rework_profiles_and_tasks.sql line 12
history: recorded in supabase_migrations.schema_migrations
MISMATCH 12/14 20240310083000_create_comments.sql
MISSING line 26 policy "Authors write comments" on public.comments (for INSERT, to authenticated)
DIFFERENT line 3 column public.comments.body: nullability expected not null, found nullable
SUPERSEDED line 34 table public.task_events (and 5 columns, 1 index, 2 constraints): dropped by 20240320090000_rework_profiles_and_tasks.sql line 16
UNVERIFIABLE line 30 GrantStmt: privileges are not checked
UNVERIFIABLE line 32 CommentStmt: comments are not checked
history: NOT recorded in supabase_migrations.schema_migrations
MISMATCH 5/6 20240315140000_task_details_and_search.sql
MISSING line 15 function public.search_tasks(text)
note: found other signatures: search_tasks(text, integer)
UNVERIFIABLE line 10 UpdateStmt: data change, not a schema object
history: recorded in supabase_migrations.schema_migrationsHow to read it:
Word | Meaning |
| Every object the migration should leave behind exists as written. In verbose output, |
| A MATCH that does not show the file ran: every check would also hold without it (only drops, a policy re-created, a function replaced, |
| At least one checked object is MISSING, DIFFERENT or UNEXPECTED; |
| Expected to exist, not found. |
| Found, but an attribute the migration asserts differs: column type or nullability, index columns or method, policy command or roles, and so on. |
| The migration drops or renames the object, but it still exists. |
| A later migration drops or rebuilds the object or what it depends on, or |
| A statement that cannot be turned into a catalog check (GRANT, COMMENT, data changes, dynamic SQL). Review it by hand. |
| No statement in the file yields a catalog check, as with a seed file. |
| Whether the version is in |
"Applied but NOT recorded" counts migrations whose objects all exist (MATCH) while the history table has no row for their version: typically SQL pasted into the Supabase SQL editor. Only positive evidence counts: at least one check must be one that would fail had the file never run. A file that only drops things, re-creates a policy with a new USING (expressions are not compared), replaces a function body (bodies are not compared) or adds back a column exactly as an earlier migration defined it matches before it runs, so it is listed apart as "NOT recorded, and their MATCH does not show they ran" and marked MATCH*. The opposite, "recorded in history but MISMATCH", lists migrations the history table claims are applied whose objects are not all there: a partial apply, or a hand edit afterwards. Those are the two cases the history table alone gets wrong.
How it works
supabase/migrations/<version>_<name>.sql
|
v
libpg-query: the Postgres 18 parser compiled to WASM (the SQL is parsed, never run)
|
v
expected objects per file: net effect of its statements, each with its line
|
v
timeline across files: a later DROP -> SUPERSEDED, a later RENAME or ALTER updates the expectation
|
v
compare with a pg_catalog snapshot <-- one REPEATABLE READ, READ ONLY transaction per tool call
| (catalog snapshot + recorded history)
v
report: MATCH n/n or MISMATCH m/n with per-object lines, as text and structuredContentA real parser, not regexes. libpg-query is the actual Postgres parser, so dollar quoting, quoted identifiers,
CREATE SCHEMA ... CREATE TABLEandDO $$ ... $$bodies (through the PL/pgSQL parser) are read exactly as the server reads them. Every statement yields expected objects, anunverifiableorignorednote, or both; nothing is silently dropped.pg_catalog, notinformation_schema.information_schemahides objects the connected role has no privileges on, so a least-privilege role would see existing tables as missing.pg_catalogis readable byPUBLIC, and every query joins by name rather than casting to::regclass, which would need schema privileges.Types in
format_type()spelling. Migration types are normalized to what the catalog prints (int4becomesinteger,varchar(255)becomescharacter varying(255),timestamptzbecomestimestamp with time zone). The catalog is read withsearch_path = '', so user types come back schema-qualified, and a barejob_statusin a migration matchespublic.job_status.Net effect per file, then a timeline. Within one file,
CREATE TABLE,ALTER TABLE ... ADD COLUMNandDROP INDEXfold into one expectation per object. Across files, each migration is judged on what should still hold today: a table dropped two migrations later is SUPERSEDED, not MISSING, and a column whose type a later migration changes is checked against the new type. Postgres's own side effects are applied the same way in one file and across files: what depends on a dropped object goes with it (indexes and constraints with their column, foreign keys with the table they reference, triggers with their function, columns with their type), and renames are followed (a column renamed later is checked under its new name with the type the earlier migration gave it, and the indexes, constraints, foreign keys, policies and RLS that use it follow along). When a later change is not in the database yet, the earlier migration still matches the object as it left it, and the later migration reports the difference. Files apply in file-name order, as the Supabase CLI applies them. A later rebuild (DROP TABLEthenCREATE TABLE, or the rename-create-drop that removes an enum value) supersedes what the rebuild leaves out. Unnamed constraints and indexes also carry the name Postgres generates for them (profiles_username_key), so a laterDROP CONSTRAINTby that name is recognized. ACREATE TABLE IF NOT EXISTSorADD COLUMN IF NOT EXISTSof something an earlier migration created was skipped by Postgres: it is checked for existence only and never changes the earlier expectation.One consistent read. The catalog snapshot and the history table are read in a single
REPEATABLE READ, READ ONLYtransaction, on a fresh connection that is closed afterwards, so every catalog query sees the same moment even while a migration runs. (Deparsed strings such asformat_typeread the current catalog, so DDL committing at that very instant can still show up in them.)
Safety model
These are hard rules, enforced in code and covered by tests:
Read-only by construction. Every query runs inside a read-only transaction (
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY) and is rolled back, never committed. SQL exists only as vetted constantSELECTqueries in src/catalog/queries.ts (the query runner accepts nothing else), and user input only travels as bind parameters. The server never runs user-supplied SQL, migration files included: they are parsed, never executed. There is no write tool.The connection string stays secret. It comes only from
PGMV_DATABASE_URLorDATABASE_URL, never from a tool argument. It is never logged, echoed in tool output, or included in error messages: database errors are mapped to safe categories, and every error text is scrubbed of the URL, password, host and port before it leaves the server. Socket and TLS errors are reduced to their code, because Node names the resolved IP address in them. A test throws nine error forms at every tool and checks that none leaks.Tools read only the project. The
dirargument comes from the model, so it must stay inside the project directory or the configured migrations directory, also after following symbolic links;PGMV_ALLOW_OUTSIDE_PROJECT=1lifts that..sqlfiles over 10 MB are listed but never read.Every call is bounded in time. Connecting gives up after 10 seconds, each statement after the statement timeout plus 2 seconds (also when the server or network goes silent), and closing the connection after 2 more.
No real data or credentials. Everything in this repository, its tests and its examples is synthetic; passwords are placeholders such as
test-only-password.
The read-only transaction protects against mistakes in this tool; the role you connect as is what protects your database. Use the role below.
Read-only role
The tool needs no table, sequence or function privileges, because pg_catalog is readable by PUBLIC. Run this as an admin role (on Supabase: in the SQL editor, as postgres):
CREATE ROLE pgmv_readonly WITH LOGIN PASSWORD '<generated password>'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 3;
-- INHERIT is the default; keep it.
ALTER ROLE pgmv_readonly SET default_transaction_read_only = on;
ALTER ROLE pgmv_readonly SET statement_timeout = '15s';
GRANT CONNECT ON DATABASE postgres TO pgmv_readonly;
-- Only for the history column and diff_history / list_recorded_migrations:
GRANT USAGE ON SCHEMA supabase_migrations TO pgmv_readonly;
GRANT SELECT ON supabase_migrations.schema_migrations TO pgmv_readonly;Do not grant
pg_read_all_data. It givesSELECTon every row of user data, and this tool never reads user data.Without the two history grants every catalog check still works; the
HISTORYcolumn shows-and the history tools explain which grant is missing.default_transaction_read_onlyis defence in depth, not a boundary: any session can turn it off. The real protection is that the role owns nothing and has no write privileges.An integration test runs the SQL above as written (with a test password and its own database name) and verifies migrations as the role it creates. The other integration tests connect as the same kind of role with no grants at all, and a 44-statement migration covering every supported object kind still verifies as
MATCH 169/169.Role names starting with
pg_are reserved by Postgres, so pick another name if you change it.
Supabase connection notes
Direct connection:
db.<project-ref>.supabase.co:5432, IPv6 only unless your project has the IPv4 add-on.Session pooler (Supavisor): port
5432on the pooler host, works over IPv4. The user name carries the project ref (pgmv_readonly.<project-ref>). Copy the exact host and user format from the dashboard's Connect panel.Transaction pooler (port
6543) has not been tested; prefer the direct connection or the session pooler.TLS: without
sslmodein the URL (orPGSSLMODEin the environment), thepgdriver connects without encryption. Unlike libpq andpsql, it does not default toprefer, and the connection strings the Supabase dashboard shows have nosslmode. Add?sslmode=verify-full, which checks the server certificate against the CAs Node trusts (pgtreatssslmode=requirethe same way). Supabase signs its certificates with its own CA, so download it from the dashboard (Database settings, SSL configuration) and add&sslrootcert=/absolute/path/to/supabase-ca.crt, as in the examples above.sslmode=no-verifyencrypts without verifying the server and is not recommended; leaving outsslmode, orsslmode=disable, is only for a local server. Also consider turning on "Enforce SSL on incoming connections" in the Supabase dashboard. The startup log line says which TLS mode applies, without naming the host.
Configuration
All configuration is read from environment variables at startup. A value that is empty, or an unexpanded ${...} placeholder left by a client, counts as unset.
Variable | Default | Purpose |
| none |
|
| none | Used when |
| the server's working directory | Directory that relative paths (the variable below and the |
|
| Migrations directory, relative to the project directory. May lie outside it. |
|
| Schema for unqualified names in migrations. |
|
| Per-statement timeout inside each read-only transaction. Must be a whole number from 1 to 2147483647, or startup fails. The client also gives up on a statement 2 seconds after it, in case the connection dropped. |
| unset |
|
Without a database URL the server still starts: list_repo_migrations and expected_objects work, and the other tools return an error explaining how to configure one. On startup the server logs one line to stderr naming the variable that supplied the URL (never its value), whether the connection uses TLS (TLS: off (...) for a remote server is a warning) and the directories it uses. npx pg-migration-verify --help lists the variables; --version prints the version.
Known limitations
pg-migration-verify checks schema objects, and is honest about what it cannot see. These are reported as UNVERIFIABLE or simply not compared:
Not checked: privileges (
GRANT,REVOKE, default privileges), ownership, roles, comments, publications, function bodies and attributes (only the signature is checked), column defaults, sequence options, partial-index predicates andINCLUDEcolumns,CHECKconstraint expressions (only the columns they reference are compared), foreign keyMATCHtype, deferrability andON DELETE SET NULL (columns)lists (ON DELETE/ON UPDATEactions are compared), data changes (INSERT,UPDATE,DELETE,COPY, ...), and dynamic SQL (EXECUTE) insideDOblocks.Policies are compared by command, roles and permissive/restrictive only.
USINGandWITH CHECKexpressions are shown but never compared.Unnamed constraints and indexes are matched by table, type and columns, so two unnamed ones on the same columns cannot be told apart. A later drop or rename by the generated name is recognized only when it is the name Postgres chooses first: a collision suffix (
profiles_username_key1) is not predicted.Dependencies the parser cannot see are not followed: a view over a dropped table, a function whose argument type is dropped. Only renames and drops the migrations spell out are applied; a rename done by hand shows as MISSING.
Apply order is file-name order, as the Supabase CLI applies migrations. With versions of different lengths (
9_a.sql,10_b.sql) that is not numeric order, and the tools say so.IF NOT EXISTSre-declarations are known to be skipped only when an earlier migration in the repository created the object; objects created outside the migrations are not known.DOblocks: static statements inside them are expected to have run, although anIF NOT EXISTSguard makes them conditional. They are flagged (inDoBlock) and reported at the line of theDOstatement.Serial columns are checked as
integer/bigint/smallint NOT NULL; the sequence behind them is not checked.Columns not written out (
LIKE,INHERITS, partitions, typed tables,CREATE TABLE AS) are not checked.History table: only
supabase_migrations.schema_migrations(Supabase CLI). Other tools' history tables are on the roadmap.Line numbers point at the start of the statement: a missing column is reported at its
CREATE TABLEline.Postgres versions tested: 15 and 17 in CI (the versions Supabase runs) and 18 locally.
Development
git clone https://github.com/ColeUD/pg-migration-verify.git
cd pg-migration-verify
npm ciScript | What it does |
| Cleans |
|
|
| ESLint with |
| Prettier, write mode. |
| Unit tests (Vitest). No database needed. |
| Integration tests against a real Postgres, see below. |
| Both test projects. |
| After |
| After |
Which database the integration tests use: if TEST_DATABASE_URL is set (a superuser URL, as in CI), that server; otherwise an embedded-postgres 18 server started in the OS temp directory (about 5 seconds for initdb). Each test file creates and drops its own database, so a shared server stays clean. CI runs them against Postgres 15 and 17 service containers.
Windows: if the repository sits in a deep directory, the embedded Postgres binaries exceed the 260-character path limit and the run stops with an explanation. Map the parent directory to a drive letter (subst P: <parent of the repo>, then run from P:\pg-migration-verify; mapping the repository itself confuses Vitest), enable long paths, or set TEST_DATABASE_URL.
See CONTRIBUTING.md for the test layout and conventions, and SECURITY.md for reporting vulnerabilities.
Roadmap
Ideas that are deliberately out of the current scope, such as suggesting the SQL for missing objects (never executing it), checking grants, other history tables and a CI exit-code mode, are in ROADMAP.md.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
PostgreSQL, MySQL, and SQL Server in one session. 26 read-only MCP tools for AI agents.
A read-only verified record of agent-operable GTM tools: search, fetch, compare, track changes.
Deterministic safety, correctness & cost gate that vets Postgres SQL before your AI agent runs it.
Your Supabase account in natural language: run SQL, apply migrations, manage tables, storage, edge f
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to safely and audibly operate PostgreSQL databases and their Docker environments, offering schema inspection, guarded queries, migrations, performance diagnosis, and container management tools.26 PyPIMIT
- AlicenseNot gradedqualityBmaintenanceLets coding agents ask whether a Supabase migration is safe to merge: it replays the base branch and working tree into throwaway databases, reports only the findings the change introduces, and proves RLS leaks by querying as anon. Tools are read-only and take profile names instead of connection strings.7 npmMIT
- AlicenseNot gradedqualityAmaintenanceExposes read-only tools that let AI assistants detect schema drift, analyze lock impact, verify data with checksums, and plan safe PostgreSQL migrations that are validated by deterministic checks and shadow runs.31 npmApache 2.0
- AlicenseNot gradedqualityCmaintenanceEnables AI coding agents to audit Supabase or Postgres databases for Row Level Security and multi-tenant isolation holes, reading tables, grants, policies and functions for static findings and optionally probing those policies inside a rolled-back transaction. It reports each hole with an explanation and a fix, so an agent can verify the migrations it just wrote before declaring them done.1MIT