A database-backed MCP server can be useful in a few dozen lines of TypeScript. It can also become an unusually efficient way to hand a language model more authority than you intended. The dangerous shortcut is a generic run_sql tool: accept a string, send it to PostgreSQL, and return whatever comes back. That design makes the model responsible for query safety, data scope, result size, and write behavior at the exact moment it is also trying to satisfy a user request.
A safer server does not expose SQL. It exposes a small vocabulary of application actions such as customers_search, customers_get, and customers_create. Each tool has a fixed purpose, a strict input schema, approved identifiers, parameterized values, and a database role that can do only what that tool requires. The model chooses among those tools; it does not invent the database interface.
MCP standardizes how clients discover and call tools. It does not decide which tables, columns, rows, or operations your server should expose. That authorization boundary remains your application’s responsibility.
Start with a threat model, not a tool handler
Before writing code, list what an untrusted or mistaken caller must not be able to do. For a customer table, that might include reading password hashes, returning the entire table, searching arbitrary columns, changing more than one record, or using a runtime credential to inspect every schema. Translate each constraint into more than one enforcement layer.
For example, hiding a sensitive column from the tool schema is useful, but the runtime database role should also lack permission to read it. Requiring an explicit customer ID in the MCP input is useful, but the repository query should also contain a primary-key predicate and reject a result count above one. Good boundaries overlap.
The architecture
DATABASE_DISCOVERY_URL
|
v
development-time schema discovery
|
v
human-reviewed allowlist
|
v
deterministic tool generation
|
v
typed MCP tools
|
v
DATABASE_RUNTIME_URL
|
v
parameterized PostgreSQL queriesThis separates a privileged development task—understanding the schema—from the long-lived process that serves tool calls. Discovery output is evidence for a developer to review, not permission for the runtime to expose everything it found.
Separate discovery credentials from runtime credentials
Schema discovery needs metadata access. The running MCP server usually does not. Give each phase its own PostgreSQL role and connection string, and never allow the runtime URL to fall back to the discovery URL. A missing restricted credential should stop startup instead of silently widening access.
# Used only by the interactive discovery command
DATABASE_DISCOVERY_URL=postgresql://mcp_discovery:...@host/database
# Used by the long-lived MCP process
DATABASE_RUNTIME_URL=postgresql://mcp_runtime:...@host/database
DATABASE_SCHEMA=public
REQUEST_TIMEOUT_MS=10000In PostgreSQL, privileges can be granted at the database, schema, table, and column levels. Use that precision. Avoid making the runtime role an owner, superuser, or Supabase service-role equivalent. The generated allowlist narrows the application surface, but it does not replace PostgreSQL permissions or Row Level Security.
create role mcp_runtime login password 'replace-me';
grant connect on database app_database to mcp_runtime;
grant usage on schema public to mcp_runtime;
grant select (id, name, email, created_at)
on table public.customers to mcp_runtime;
grant insert (name, email)
on table public.customers to mcp_runtime;A query working in the Supabase SQL editor proves very little if the editor uses a privileged role. Run verification through the same restricted credential the MCP process will use.
Discover broadly, approve narrowly
Automated discovery is valuable because hand-maintained schemas drift. But discovery should produce a review artifact, not mutate the live tool registry. A useful approval step shows every candidate entity and operation, readable and writable columns, likely sensitive fields, maximum row counts, and whether unfiltered search is allowed.
New write operations should default to disabled. Views should remain read-only. Entities without a reliable single-column primary key should not receive get, update, or delete tools until their identity model is explicit. If a newly added table becomes callable merely because it appeared in public, discovery has become an escalation path.
export default defineDatabaseConfig({
schema: 'public',
entities: {
customers: {
primaryKey: 'id',
operations: {
search: true,
get: true,
create: true,
update: false,
delete: false,
},
readableColumns: ['id', 'name', 'email', 'created_at'],
searchableColumns: ['name', 'email'],
creatableColumns: ['name', 'email'],
maximumRows: 20,
allowUnfilteredSearch: false,
},
},
})Generate application tools, not a SQL console
The approved configuration can now produce stable, table-specific tools. A search tool accepts only generated filters and search fields. A get tool accepts the known primary-key type. A create tool accepts only approved columns. Update and delete tools operate on one explicit record. Identifiers come from generated code, never from model input.
This is a deliberate loss of flexibility. The model cannot improvise a join, query a new table, or select a hidden column. That is the point. Add a new capability by reviewing and shipping a new tool, not by hoping a prompt will constrain a universal query endpoint.
const CustomersSearchInput = z.object({
query: z.string().trim().min(1).max(200).optional(),
created_at: z.string().datetime().optional(),
limit: z.number().int().min(1).max(20).default(20),
cursor: z.string().optional(),
}).refine(
input => input.query || input.created_at,
'At least one approved search condition is required',
)The official TypeScript SDK supports input schemas and structured tool results. Use the schema as an enforcement point, not merely as documentation. Reject unknown keys, cap string and array sizes, validate identifiers by type, and return predictable errors that help the model correct its call without revealing connection details.
Keep every query bounded and parameterized
Parameterized values prevent user input from changing SQL structure. They do not make model-provided table or column names safe, because most PostgreSQL clients cannot parameterize identifiers. Keep identifiers inside reviewed generated code and parameterize only values.
const result = await pool.query(
`select "id", "name", "email", "created_at"
from "public"."customers"
where "name" ilike $1 or "email" ilike $1
order by "id"
limit $2`,
[`%${query}%`, Math.min(limit, 20)],
)Every list operation needs a hard server-side maximum, deterministic ordering, and pagination. Do not trust the model to request a modest limit. Add a request timeout and cancellation path so an expensive query cannot occupy a pooled connection indefinitely. Return a cursor rather than encouraging offset scans over an unbounded table.
Treat writes as a separate class
MCP tool annotations can tell a client whether a tool is read-only, destructive, or idempotent, which helps clients present approval controls. They are hints, not enforcement. A write tool still needs a restricted role, explicit input fields, a one-record predicate where appropriate, and a host approval policy that asks before consequential actions.
customers_create is easier to review than mutate_database. Specific names, narrow descriptions, and accurate read/write annotations give both the model and the human a clearer decision.
Design errors for correction without leakage
A tool error should explain what the caller can change: an email is invalid, a customer was not found, a filter is required, or the request exceeded a limit. It should not echo a connection string, raw driver object, SQL statement with private values, or full stack trace. Log structured diagnostics to stderr for a local STDIO server so protocol messages on stdout remain clean.
Redact credentials before logging configuration failures. Assign stable error codes to expected failures. Keep the detailed cause in operator logs and return a smaller corrective message to the model. Timeouts, aborted requests, permission errors, and validation errors should be distinguishable without exposing secrets.
Test the boundary, not just the happy path
A server that returns one customer in a demo has not proven its safety model. Unit tests should attempt unknown fields, oversized limits, unfiltered searches, multi-row writes, invalid cursors, sensitive columns, timeouts, and database errors containing credential-like strings. Generation tests should prove that failed validation preserves the previous generated output.
Keep PostgreSQL integration tests opt-in and run them against a disposable schema. Verify the runtime role itself: allowed reads succeed, hidden columns fail, disabled writes fail, and Row Level Security behaves as expected. Run the normal suite without production secrets so CI can validate the deterministic surface on every platform.
npm run doctor
npm run format:check
npm run typecheck
npm test
npm run build
# Against a disposable PostgreSQL database only
npm run test:integrationA practical first release
Start with one entity and two or three tools. For the customer example, search, get, and create are enough to validate the full architecture: schema discovery, approval, type mapping, repository queries, tool registration, diagnostics, and permissions. Leave update and delete disabled until the product actually needs them and you have tested their approval experience.
The Dev Fieldnotes PostgreSQL MCP Starter Kit packages this exact workflow: interactive schema discovery, a reviewed allowlist, deterministic TypeScript generation, separate runtime credentials, a doctor command, tests, Docker, and a small customer demo. It exists to save the setup work, not to remove the review step. You still decide which data and operations belong in your server.
If you want to start from this architecture instead of assembling it from scratch, explore the Dev Fieldnotes PostgreSQL MCP Starter Kit. The product page documents the current private-beta scope, included safeguards, and launch list.
The rule to carry forward
A safe database MCP server makes authority explicit. Discover the schema during development, require a human to approve the model-facing surface, generate narrow tools, run them with a restricted database role, parameterize every value, bound every result, and test the forbidden paths.
The best MCP tool is not the one that can answer every possible database question. It is the smallest reliable capability that lets the model complete a real task without gaining accidental access to everything behind it.
