Ptah MCP: Inspect the Tools Before You Trust the Write
Connect MCP Inspector to Ptah, inspect a migration patch, approve it once, and see stale previews refused before deploying to PostgreSQL.
Ptah helps you plan, review, and apply database migrations. Try in your browser
An assistant proposes a migration. Before letting it write, you want to see which files it can read, the exact SQL it will create, and what happens if a colleague edits the directory while you review the proposal.
The interactive Assist walkthrough shows a model revising a shipment requirement in conversation. Here we open Ptah’s tools directly in MCP Inspector 2.7.0. We submit the calls ourselves and watch Ptah 0.13.0 enforce its permissions against a synthetic workspace and PostgreSQL 18.6. No model participates in this Inspector run; the SQL is a fixture we supply.
Connect the server and open Tools
Section titled “Connect the server and open Tools”The example files include a starting migration, two queued
shipments, and an Inspector configuration with two servers. Set the disposable
database URL in each server’s env.PTAH_DATABASE_URL field in mcp.json,
put Ptah on PATH, and replace /path/to/project with your absolute workspace
path. Keep the edited local configuration out of version control. Install the
pinned Inspector dependencies with npm ci, then open its web client:
npx --no-install mcp-inspector --web --config mcp.jsonInspector does not forward an arbitrary shell variable to a stdio server.
Exporting PTAH_DATABASE_URL without the explicit env entry left our first
read_database call with no_database_target. The supplied configuration
names that entry; replace its placeholder in both profiles.
On the Servers screen, switch on ptah-readonly. Open Tools after it
connects. Our discovery result contains 11 tools, including describe_session,
read_database, read_artifact, preview_patch, and apply_patch.
The banner saying Read-only session refers to Inspector’s server catalog:
it cannot save edits to the configuration loaded with --config. Ptah’s own
permissions come from the server invocation. Call describe_session to
inspect those permissions. In this first profile, migration writes are denied.
The connection guide
explains how the same stdio server connects to other MCP clients.
Call read_database with {"target":"lab","schemas":["app"]}. Selected
fields from the response show what the client learns about the table:
{ "dialect": "postgres", "objects": [ { "columns": 3, "kind": "table", "name": "shipments", "schema": "app" } ], "version": "PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit"}This result contains an object summary, not shipment rows. We also tried
adding a query argument to request rows; input validation rejected it.
Migration SQL remains available through the separate artifact reader.
Read the files and preview the change
Section titled “Read the files and preview the change”Choose read_artifact and enter {"artifact":"migrations"} in the JSON
arguments editor. Its response includes the current files and directory
digest. Copy that digest into expected_digest in the supplied
preview arguments, then call
preview_patch with those arguments.
The fixture adds an optional tracking code. Populated codes must be globally unique; multiple shipments may still have no code. This deliberately uses the simpler requirement before the carrier-specific revision in the Assist article. The returned up-file diff is:
--- /dev/null+++ 1790000200_add_tracking_code.up.sql@@ -1,0 +1,2 @@+ALTER TABLE app.shipments ADD COLUMN tracking_code text;+CREATE UNIQUE INDEX shipments_tracking_code_key ON app.shipments (tracking_code) WHERE tracking_code IS NOT NULL;The down file drops the index and column. Preview reports both creations,
the base and projected directory digests, and a patch ID. Preview itself
writes nothing. In this read-only profile, requires_approval is false:
the write is denied rather than offered for approval. Check the session
policy before treating that field as permission to write.
Try apply_patch on ptah-readonly, passing the returned preview_token
and patch_id. Our result was:
capability_denied: "artifact.write:migrations" denied by invocation policy. The operator decides this when starting the server; describe_session reports what this session may do.The migration directory’s digest stayed unchanged. Discovering a write tool in the client does not give that client permission to use it.
Approve the exact patch once
Section titled “Approve the exact patch once”Disconnect ptah-readonly, then connect ptah-review. The second server
adds only --allow-write migrations to the Ptah invocation. Its
describe_session response now includes this capability decision:
{ "capability": "artifact.write:migrations", "decided_by": "invocation", "verdict": "ask"}Read the artifact again and create a fresh preview on this connection. This
profile returns requires_approval: true. Call
apply_patch with the new token and patch ID. Inspector opens an
Elicitation Request form. The following excerpt selects and line-wraps
text from the actual prompt:
Ptah is asking for artifact.write:migrations.create: 1790000200_add_tracking_code.down.sqlcreate: 1790000200_add_tracking_code.up.sqlThis approval covers exactly sha256:c14561b4d8fe67281ab68af842466d53d9c1c31d4635cded6414c6dae25422f9 and nothing else.Review both SQL files and the patch ID. Enter allow once in the decision
field and click Submit. In the form’s JSON editor, that answer is
{"decision":"allow once"}. These words authorize this patch only.
The call returns the write result. Selected fields from our response:
{ "approved": true, "rolled_back": false, "integrity_refreshed": true, "introduced": [], "verification": { "ok": true, "results": [ { "diagnostics": [], "gate": "migration-integrity", "ok": true }, { "diagnostics": [], "gate": "migration-sql", "ok": true } ], "skipped": 0 }}Both verification gates passed. The two new files appeared and ptah.sum
was refreshed. Hash checks confirmed that the original migration pair and
declared schema were unchanged. The
reviewed-change guide
describes the preview and approval lifecycle.
Change a file while the preview is open
Section titled “Change a file while the preview is open”Before that successful write, we tested a competing edit. We previewed the
patch, then appended a known comment to the original up migration. When we
approved the now-stale preview, Ptah returned digest_mismatch.
The refusal preserved our comment and created neither proposed file. We removed only the test comment, read the directory again, and made the fresh preview used above. An approval cannot make an old directory digest current.
After the successful write, we submitted its token again. The response was:
unknown_preview: unknown or expired preview: preview the patch again and apply the token it returns| Client action | Observed result |
|---|---|
| Read a path outside the migration artifact | unsafe_path |
| Apply through the read-only server | capability_denied; directory unchanged |
| Approve after a concurrent file edit | digest_mismatch; edit preserved |
| Approve the fresh patch once | Two files created; checksum refreshed; gates passed |
| Reuse the consumed preview token | unknown_preview |
The full records retain the requests, responses, approval prompt, and Ptah audit. Local paths and preview tokens are consistently substituted. Recorded tokens cannot authorize a new run.
Deploy the files through the regular CLI
Section titled “Deploy the files through the regular CLI”A separate database query after the MCP write still found the original three columns. The tool had created migration files; the database had not changed. Ptah’s MCP tool reference defines this file-review surface.
With PTAH_DB_URL set to the same disposable database, we validated the
reviewed directory and deployed through the ordinary CLI:
ptah migrations validate --dir migrationsptah migrations up --migrations-dir migrations --verify-sum --dry-runptah migrations up --migrations-dir migrations --verify-sumptah schema compare --schema-file expected-schema.sql --schemas app --exit-codepython application.pyValidation and schema comparison passed. The Python client, using Psycopg 3.3.6, exercised shipment updates and lookups. Its output included:
Duplicate tracking code refused: SQLSTATE 23505Shipment writes, lookup, duplicate refusal, and multiple NULL values passed.The original queued rows retained NULL codes. Assigning PKG-001 to the
first shipment worked; assigning it to the second failed with SQLSTATE
23505 and left that row unchanged. PKG-002 worked for the second shipment,
and two more NULL codes were accepted.
A fresh database replay produced the same client output and schema. Retrying the original deployment found no pending migration. Rolling the replay back to its starting version restored the original columns and retained all four rows, while discarding the tracking values. Both disposable databases were removed afterward.
This tiny sequential test does not measure production lock time or concurrent traffic. Its ordinary index creation can block writers. The migration linter walkthrough shows how to examine that deployment choice before using a larger table.
Use the same tools from your assistant
Section titled “Use the same tools from your assistant”An MCP-capable assistant replaces the manual tool selections in this example. Choose a client that supports the approval flow and configure the same scoped Ptah server. For a native conversation, use Ptah Assist.
The client receives tool results, including migration contents and workspace metadata. If it forwards them to a hosted model, that is an explicit data boundary to evaluate with your provider. Inspector alone did not contact a model in this run. Keeping the client, model, Ptah, and database in your own infrastructure lets you operate that workflow under your own control.
Put Ptah to work
Example files
- application.py
- expected-schema.sql
- initial/migrations/1790000100_create_shipments.down.sql
- initial/migrations/1790000100_create_shipments.up.sql
- initial/migrations/ptah.sum
- mcp.json
- measured/after-approved-artifact.protocol.json
- measured/after-approved-artifact.request.json
- measured/after-approved-artifact.result.txt
- measured/after-dry-run.stderr.txt
- measured/after-dry-run.stdout.txt
- measured/after-mcp-before-deployment.stderr.txt
- measured/after-mcp-before-deployment.stdout.txt
- measured/after-readonly.protocol.json
- measured/after-readonly.request.json
- measured/after-readonly.result.txt
- measured/after-write-database.protocol.json
- measured/after-write-database.request.json
- measured/after-write-database.result.txt
- measured/agent-audit.json
- measured/application.stderr.txt
- measured/application.stdout.txt
- measured/approval-answer.json
- measured/approval-excerpt.txt
- measured/approved-apply-approval.json
- measured/approved-apply-prompt.txt
- measured/approved-apply.protocol.json
- measured/approved-apply.request.json
- measured/approved-apply.result.txt
- measured/approved-preview.protocol.json
- measured/approved-preview.request.json
- measured/approved-preview.result.txt
- measured/before-approved-artifact.protocol.json
- measured/before-approved-artifact.request.json
- measured/before-approved-artifact.result.txt
- measured/cleanup-confirm.stderr.txt
- measured/cleanup-confirm.stdout.txt
- measured/cleanup-container-absent.stderr.txt
- measured/cleanup-container-absent.stdout.txt
- measured/cleanup-container.stderr.txt
- measured/cleanup-container.stdout.txt
- measured/cleanup-mcp_lab_replay.stderr.txt
- measured/cleanup-mcp_lab_replay.stdout.txt
- measured/cleanup-mcp_lab.stderr.txt
- measured/cleanup-mcp_lab.stdout.txt
- measured/cleanup.json
- measured/client-excerpt.txt
- measured/commands.json
- measured/create-replay.stderr.txt
- measured/create-replay.stdout.txt
- measured/database-assertions.json
- measured/database-result.json
- measured/deploy.stderr.txt
- measured/deploy.stdout.txt
- measured/dry-run.stderr.txt
- measured/dry-run.stdout.txt
- measured/explicit-env-control.stderr.txt
- measured/explicit-env-control.stdout.txt
- measured/final-file-check.json
- measured/fresh-stale-preview.protocol.json
- measured/fresh-stale-preview.request.json
- measured/fresh-stale-preview.result.txt
- measured/hash-initial.stderr.txt
- measured/hash-initial.stdout.txt
- measured/initial-artifact.protocol.json
- measured/initial-artifact.request.json
- measured/initial-artifact.result.txt
- measured/initial-file-hashes.json
- measured/initial-migration.stderr.txt
- measured/initial-migration.stdout.txt
- measured/integrity.stderr.txt
- measured/integrity.stdout.txt
- measured/missing-env-refused.stderr.txt
- measured/missing-env-refused.stdout.txt
- measured/outside-artifact.protocol.json
- measured/outside-artifact.request.json
- measured/outside-artifact.result.txt
- measured/policy-excerpt.json
- measured/postgres-image.stderr.txt
- measured/postgres-image.stdout.txt
- measured/postgres-version.stderr.txt
- measured/postgres-version.stdout.txt
- measured/preview-arguments.json
- measured/preview-up.diff.txt
- measured/psycopg-version.stderr.txt
- measured/psycopg-version.stdout.txt
- measured/python-version.stderr.txt
- measured/python-version.stdout.txt
- measured/read-database.protocol.json
- measured/read-database.request.json
- measured/read-database.result.txt
- measured/readonly-apply.protocol.json
- measured/readonly-apply.request.json
- measured/readonly-apply.result.txt
- measured/readonly-assertions.json
- measured/readonly-preview.protocol.json
- measured/readonly-preview.request.json
- measured/readonly-preview.result.txt
- measured/readonly-session.protocol.json
- measured/readonly-session.request.json
- measured/readonly-session.result.txt
- measured/release-identity.json
- measured/render-schema.protocol.json
- measured/render-schema.request.json
- measured/render-schema.result.txt
- measured/repeat-apply.protocol.json
- measured/repeat-apply.request.json
- measured/repeat-apply.result.txt
- measured/replay-application.stderr.txt
- measured/replay-application.stdout.txt
- measured/replay-schema.stderr.txt
- measured/replay-schema.stdout.txt
- measured/replay-seed.stderr.txt
- measured/replay-seed.stdout.txt
- measured/replay.stderr.txt
- measured/replay.stdout.txt
- measured/retry.stderr.txt
- measured/retry.stdout.txt
- measured/review-artifact.protocol.json
- measured/review-artifact.request.json
- measured/review-artifact.result.txt
- measured/review-session.protocol.json
- measured/review-session.request.json
- measured/review-session.result.txt
- measured/rollback-columns.stderr.txt
- measured/rollback-columns.stdout.txt
- measured/rollback-one.stderr.txt
- measured/rollback-one.stdout.txt
- measured/rollback-rows.stderr.txt
- measured/rollback-rows.stdout.txt
- measured/row-query-refused.protocol.json
- measured/row-query-refused.request.json
- measured/row-query-refused.result.txt
- measured/schema-compare.stderr.txt
- measured/schema-compare.stdout.txt
- measured/seed.stderr.txt
- measured/seed.stdout.txt
- measured/stale-apply-approval.json
- measured/stale-apply-prompt.txt
- measured/stale-apply.protocol.json
- measured/stale-apply.request.json
- measured/stale-apply.result.txt
- measured/stale-file-after.json
- measured/stale-file-before.json
- measured/tools-list.stderr.txt
- measured/tools-list.stdout.txt
- measured/validate-schema.protocol.json
- measured/validate-schema.request.json
- measured/validate-schema.result.txt
- measured/version.stderr.txt
- measured/version.stdout.txt
- measured/write-result.json
- migrations/1790000100_create_shipments.down.sql
- migrations/1790000100_create_shipments.up.sql
- migrations/1790000200_add_tracking_code.down.sql
- migrations/1790000200_add_tracking_code.up.sql
- migrations/ptah.sum
- package-lock.json
- package.json
- README.md
- requirements.txt
- schema/schema.sql
- seed.sql
- verified.json