Cursor's advantage for Postgres MCP work is proximity — the agent has your open ORM models, your migration files, and your test suite in the same context window as the live database connection, so it can generate code that actually matches both the schema and your existing conventions in one pass. This topic covers wiring the server into Cursor's Agent mode, generating ORM models and types from a live schema, writing tests that assert real database state, and the limitations worth knowing before trusting it with anything close to production.
Connecting Postgres MCP to Cursor Agent Mode
Cursor reads MCP servers from .cursor/mcp.json (project) or ~/.cursor/mcp.json (global). Project-scoped, same reasoning as every other client in this module — the connection string shouldn't live in global config shared across unrelated repos:
{
"mcpServers": {
"postgres": {
"command": "uvx",
"args": [
"postgres-mcp",
"--access-mode=restricted",
"${POSTGRES_MCP_URI}"
]
}
}
}
Cursor Settings → MCP shows connected servers with a green dot and the tool count. Enable the server for Agent mode specifically — Cursor lets you toggle MCP tool availability per mode (Chat vs Agent vs Inline Edit), and it's easy to have it enabled in Chat but forget to flip it on for Agent, which is where you actually want it for multi-step database work.
Cursor Settings > MCP
postgres ● 5 tools available
[x] Available in Agent
[x] Available in Chat
POSTGRES_MCP_URI needs to be visible to whatever process launches Cursor — on macOS, GUI apps often don't inherit shell profile exports unless you set the variable via launchctl setenv or hard-code it (less ideal) directly in .cursor/mcp.json's env block instead of interpolating from the shell:
{
"mcpServers": {
"postgres": {
"command": "uvx",
"args": ["postgres-mcp", "--access-mode=restricted"],
"env": {
"DATABASE_URI": "postgresql://mcp_agent:CHANGE_ME@localhost:5432/app_dev"
}
}
}
}
If you go this route, at minimum keep .cursor/mcp.json out of git (.cursor/mcp.json in .gitignore, with a .cursor/mcp.json.example checked in for teammates) since the literal credential now lives in the file itself.
Tips
- Explicitly enable the Postgres server for Agent mode — the per-mode toggle is easy to miss and leaves the server connected but unused where you need it most.
- If shell-exported env vars don't reach Cursor (common on macOS GUI launches), use the server'senvblock directly and gitignore the file instead of relying on shell interpolation.
- Restart Cursor fully (not just reload window) after editing.cursor/mcp.json— partial reloads sometimes keep the old server process running with stale config.
Generating ORM Models and Type Definitions from a Live Schema
This is Cursor's strongest use case for Postgres MCP: it can read your existing ORM conventions from open files, inspect the live schema, and generate a new model that actually matches both.
Prompt (Agent mode, with models/customer.py and models/order.py open
for style reference):
There's a new `subscriptions` table in the database that doesn't have
a SQLAlchemy model yet. Use postgres MCP to inspect its actual columns,
types, defaults, and constraints, then generate a model matching the
style of the existing models in this file, including proper nullable/
not-null and relationship() mappings.
Real output, having inspected an actual table with a CHECK constraint and a partial unique index:
from sqlalchemy import (
Column, Integer, String, Numeric, DateTime, ForeignKey, CheckConstraint,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .base import Base
class Subscription(Base):
__tablename__ = "subscriptions"
__table_args__ = (
CheckConstraint(
"status IN ('active', 'canceled', 'past_due', 'trialing')",
name="ck_subscriptions_status",
),
)
id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
plan_code = Column(String(32), nullable=False)
status = Column(String(16), nullable=False, server_default="trialing")
monthly_amount_cents = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
canceled_at = Column(DateTime(timezone=True), nullable=True)
customer = relationship("Customer", back_populates="subscriptions")
The CheckConstraint mirroring the database's actual CHECK (status IN (...)) constraint — rather than the agent inventing a plausible-looking enum from column naming alone — came directly from get_object_details returning the real constraint definition. This is the specific value of live schema access over an LLM's general SQLAlchemy knowledge: it modeled the constraint that actually exists, not one it assumed should exist.
Follow-up for a strongly-typed frontend consuming the same table via an API:
Prompt:
Generate a matching TypeScript interface for the API response shape
of this subscription resource, based on the model you just wrote,
including a union type for the status field matching the CHECK
constraint values exactly.
// types/subscription.ts
export type SubscriptionStatus = 'active' | 'canceled' | 'past_due' | 'trialing';
export interface Subscription {
id: number;
customerId: number;
planCode: string;
status: SubscriptionStatus;
monthlyAmountCents: number;
createdAt: string; // ISO 8601
canceledAt: string | null;
}
Tips
- Keep at least one existing model file open in context when asking for a new one — style-matching (naming, relationship patterns, base class conventions) works much better with a concrete example present than from a description of your conventions.
- Explicitly ask it to mirror realCHECKconstraints and defaults fromget_object_details, not infer them from column names — this is the actual differentiator over a schema-blind code generator.
- Generate the TypeScript/API-facing type in the same session, from the same model — doing it separately risks the two drifting out of sync on enum values or nullability.
Writing Tests That Assert Real Database State via Cursor
A pattern worth adopting deliberately: let the agent write integration tests that assert against real query results it validated live via MCP, rather than assertions it guessed at.
Prompt:
Write a pytest integration test for the subscription cancellation
flow. Before writing the assertions, use postgres MCP to check what
a canceled subscription actually looks like in the current data
(query for one with status='canceled') so the test asserts realistic
field values, not assumptions.
-- what the agent runs first
SELECT * FROM subscriptions WHERE status = 'canceled' LIMIT 3;
Observed: canceled_at is always set, monthly_amount_cents is retained
(not zeroed) on cancellation, and status stays 'canceled' rather than
being deleted. Writing the test against that actual behavior.
def test_cancel_subscription_sets_canceled_at_and_retains_amount(db_session, subscription_factory):
sub = subscription_factory(status="active", monthly_amount_cents=2900)
cancel_subscription(db_session, sub.id)
db_session.refresh(sub)
assert sub.status == "canceled"
assert sub.canceled_at is not None
# amount is retained for historical/billing-reconciliation purposes,
# confirmed against production data — do not zero this on cancel
assert sub.monthly_amount_cents == 2900
That inline comment explaining why monthly_amount_cents isn't asserted as zero is the kind of context that prevents a future contributor from "fixing" the test to zero it out, breaking a real business assumption. It's a direct product of having checked real data before writing the assertion, and it's worth explicitly asking the agent to document that reasoning in the test rather than leaving a bare assertion.
Tips
- Have the agent inspect real rows matching the state under test before writing assertions — guessed field values in tests silently drift from actual behavior and give false confidence.
- Ask for an inline comment explaining any assertion that reflects a non-obvious business rule discovered from real data — future maintainers (including future-you) need that context, not just the assertion.
- Run these tests against a dedicated test database populated from a sanitized snapshot or factories, never against the same connection used for live schema exploration — mixing test writes and the read-only exploration role is a good way to end up with an accidentally-writable "read-only" connection.
Known Limitations and Safety Concerns for Postgres MCP in Cursor
Agent mode's broader tool access compounds risk. Cursor's Agent mode typically also has file-write and terminal-execute access in the same session as the Postgres MCP connection. That combination — file access, shell access, and a live database connection together — is exactly the "broad tool access plus DB write access in one session" pattern flagged as a risk in this module's first topic. If Agent mode in your setup has anything beyond a read-only mcp_agent role, a prompt-injection payload picked up from a fetched URL or a malicious file the agent reads could realistically chain into a database write. Keep the Postgres role read-only for any Cursor session that also browses the web or executes arbitrary shell commands.
No persistent audit log surfaced in the UI. Cursor shows tool calls inline in the chat/agent transcript for the current session, but there's no built-in searchable history of execute_sql calls across sessions the way a dedicated query-log pipeline would give you. For anything you'd want to audit later, rely on Postgres's own logging (log_statement = 'mod' at minimum, or full statement logging for the mcp_agent role specifically) rather than Cursor's transcript.
Context window competition with open files. Because Cursor keeps your open editor tabs in context alongside MCP tool results, a large EXPLAIN output or a wide SELECT * result competes for the same budget as your open source files. On large monorepos with many tabs open, this can push out schema details the agent fetched earlier in the session — closing unrelated tabs before a heavy database-exploration task measurably helps here.
Model-dependent tool-calling reliability. Cursor lets you pick the underlying model per session; not every model Cursor offers handles multi-step MCP tool chains (list tables → get details → run explain → validate index) with the same reliability. If a session seems to "forget" to check something it should, before assuming the MCP server is broken, check which model was selected — this has been the actual cause more than once in practice.
Tips
- Keep the Postgres MCP role strictly read-only for any Cursor session where Agent mode also has file/shell access — treat that combination as inherently higher-risk regardless of how careful your prompts are.
- Enable Postgres statement logging for themcp_agentrole (ALTER ROLE mcp_agent SET log_statement = 'all';) if you need an audit trail Cursor's own UI doesn't provide.
- Close unrelated editor tabs before a heavy schema-exploration orEXPLAINsession to free up context budget for MCP tool results.
- If multi-step MCP tool chains seem unreliable, check which underlying model is selected for the session before concluding the server itself is misconfigured.
Tips
Tips
- Cursor's real strength here is style-matched code generation from a live schema — keep a reference file open and lean on that, rather than treating it as just another way to runexecute_sql.
- Never pair a write-capable Postgres MCP connection with Cursor Agent mode's default file/shell access in the same session — read-only is the safe default, full stop.
- Validate assertions and generated models against real inspected rows (viaexecute_sql/get_object_details), not assumptions — it's the specific advantage live MCP access has over a schema-blind code generator, and skipping it gives most of that advantage back.