A Postgres MCP server is a small Node.js or Python service that lets Claude (or any MCP-compatible client) run safe, predefined queries against a Postgres database, and it is the simplest way to give an LLM a real database to work with — because the LLM can describe what it wants in plain English, the MCP server can translate that into a query, and the database can return the rows. The naive answer is “connect Claude to Postgres and let it run any query.” The working answer is the token-saving pattern that keeps the LLM from burning its context window, the read-only default that should be non-negotiable, the schema-exploration flow that prevents hallucinated columns, and the deploy step that turns a 200-line script into a runtime with a URL, logs, and a restart policy.
The reason “postgres mcp server” is its own question and not just “MCP” or “Claude Postgres” is that the database is the part that makes MCP real. An LLM that can read a CSV is a demo. An LLM that can run a query against a production Postgres is a tool. The MCP server is the layer that makes the LLM safe to use with a real database — the layer that enforces the read-only default, the layer that paginates the results, the layer that returns the schema in a way the LLM can read, and the layer that turns a one-off prototype into a runtime the team can operate.
Table of contents
- The short version
- The five problems a naive Postgres MCP server has, and the five fixes
- The token-saving pattern (the one nobody writes about)
- The read-only default (the one that should be non-negotiable)
- The schema-exploration flow (the one that prevents hallucinated columns)
- The deploy step (the one that turns a script into a runtime)
- How this fits the rest of the deploy
- FAQ
The short version
The Model Context Protocol (MCP) is a standard for connecting LLM clients (Claude Desktop, Claude Code, Cursor, Continue, etc.) to external tools and data sources. A Postgres MCP server is an MCP server that exposes a set of tools for interacting with a Postgres database.
The five problems a naive Postgres MCP server has, and the five fixes
A naive Postgres MCP server is a 200-line script that connects Claude to a Postgres database and lets it run any query. The naive version works for a demo. The naive version is the wrong answer for production, and the naive version has five specific problems.
Problem 1: The LLM can SELECT * from a million-row table and burn the context window. The fix is pagination. The server should inject a LIMIT clause into every SELECT query that does not already have one, default to LIMIT 100, allow the LLM to override up to LIMIT 1000, and fetch one extra row to detect whether more data exists. The response should include a hint like "100 rows shown, more available - use offset=100 for next page or count_rows for total". The fix is the lever that turns “I burned 50,000 tokens on a single query” into “I burned 500 tokens on a useful query.”
Problem 2: The LLM can run DROP TABLE users and destroy the database. The fix is a read-only default. The server should connect to Postgres as a user with only the SELECT privilege (or, at minimum, should reject any non-SELECT statement at the application layer before sending it to the database). The fix should be visible in the tool description (“This tool runs SELECT queries only”), the fix should be enforced in the connection string (?sslmode=require&default_transaction_read_only=on), and the fix is the lever that turns “Claude can drop my table” into “Claude can only read my table.”
Problem 3: The LLM hallucinates column names because it does not know the schema. The fix is a get_schema_info tool. The tool should return the table names, column names, column types, primary keys, foreign keys, and indexes for the database (or for a specific schema or table if the LLM narrows the request). The tool should support filtering (by schema_name, by table_name, by compact: true for just names), and the tool should be the first thing the LLM calls in a new conversation. The fix is the lever that turns “I made up a column that does not exist” into “I asked the schema first, then queried.”
Problem 4: The LLM runs a query, gets 10,000 rows, and cannot find the one it cares about. The fix is a count_rows tool (and a LIMIT clause in query_database). The LLM should be able to ask “how many rows are in this table” before running the actual query, and the LLM should be able to add WHERE clauses based on the count. The fix is the lever that turns “I queried a million rows and the response was useless” into “I queried the right rows and the response was useful.”
Problem 5: The LLM’s queries leak data to the LLM provider. The fix is privacy mode. Claude Desktop has a “Privacy Mode” setting that disables training on the user’s data; Cursor has the same; Claude Code has it built in. The fix is the right answer for any team that is querying a database with PII, the fix is the right answer for any team that is subject to GDPR / HIPAA / SOC 2, and the fix is the lever that turns “our database rows are being used to train the next model” into “our database rows are not being used to train the next model.”
The five problems are the floor. There is also the “the LLM can be tricked into a SQL injection via a prompt” problem (the fix is parameterized queries, which the pg driver does by default), the “the LLM can be tricked into a denial-of-service via a long-running query” problem (the fix is a statement timeout, SET statement_timeout = '5s'), and the “the MCP server is a single point of failure” problem (the fix is a deploy with a restart policy and a health check).
The token-saving pattern (the one nobody writes about)
The most underrated lesson in building a Postgres MCP server is the token budget. A single SELECT * on a modest table can return tens of thousands of rows, each with a dozen columns, and every character of that output consumes tokens. Multiply that across a conversation where the LLM is exploring a schema, running queries, and refining its understanding, and the LLM can burn through its context window before it has done anything useful. The pattern is the right answer for “I cannot get the LLM to do anything useful with my database,” and the pattern is the lever that turns “the LLM runs out of context after two queries” into “the LLM can run ten useful queries in the same conversation.”
The pattern has three parts:
1. Use TSV (tab-separated values) for query results, not JSON. JSON repeats the column names as keys on every row, wraps every value in quotes, and adds punctuation. TSV uses a single header row and tabs between values. TSV typically uses 30-40% fewer tokens than JSON for the same data, and TSV is trivially easy for any LLM to parse (no special prompting required).
2. Filter the schema by default. A get_schema_info tool that returns the full database schema (every table, every column, every type, every index) is a tool that is going to return a 50,000-token response for a database with 100 tables. The fix is to support filters (schema_name, table_name, compact: true for just names, vector_tables_only: true for pgvector), and the fix is to default to the most-narrow response that the LLM is likely to want. The fix is the lever that turns “the schema response is bigger than my context window” into “the schema response is 200 tokens.”
3. Embed guidance in the tool description. The LLM reads the tool description and (usually) follows the guidance. The query_database tool description should say “Start with LIMIT 10 for exploratory queries. Use WHERE clauses to filter results. Use the count_rows tool first if you are unsure how many rows a table has.” The get_schema_info tool description should say “Pass a table_name to narrow the response. Use compact: true for just table and column names.” The pattern is the right answer for “the LLM is not using the tools well,” and the pattern is the lever that turns “the LLM is guessing” into “the LLM is following the playbook.”
The three parts are the floor. There is also a count_rows tool that returns a single integer (and a few bytes of metadata), a summarize_query tool that runs a query and returns a count + first row + last row instead of the full result, and a paginate_query tool that returns a specific page of a known query.
The read-only default (the one that should be non-negotiable)
The most important design decision in a Postgres MCP server is the read/write default. The default should be read-only. The default should be read-only because the cost of a wrong write (a dropped table, a deleted user, a corrupt row) is much higher than the cost of a wrong read (a wrong answer, a hallucinated column), because the LLM is going to make mistakes (it is going to hallucinate a WHERE clause, it is going to miss a JOIN, it is going to misread a column), and because the LLM is going to be called by a user who does not know SQL (the user cannot catch the LLM’s mistake).
The read-only default is implemented in three places:
1. The database user. The Postgres user that the MCP server connects as should have only the SELECT privilege on the relevant schemas. The user should not have INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, or GRANT. The pattern is the right answer for any production database, the pattern is the right answer for any database the LLM is going to touch, and the pattern is the lever that turns “the LLM can do anything” into “the LLM can only read.”
2. The connection string. The Postgres connection string should include default_transaction_read_only=on (or the equivalent in the connection options). The setting is enforced at the connection level, the setting cannot be bypassed by the application, and the setting is the lever that turns “the application can do anything” into “the application can only read.”
3. The application layer. The MCP server should reject any non-SELECT statement before sending it to the database. The rejection should be visible in the tool description (“This tool runs SELECT queries only. INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, and GRANT are rejected.”), the rejection should return a clear error to the LLM, and the rejection is the lever that turns “the LLM tried to do something dangerous” into “the LLM got a clear error and tried something else.”
The three are the floor. There is also the “run the query in a transaction and roll it back” pattern (the right answer for INSERT/UPDATE/DELETE the LLM should be able to do, but that the developer wants to verify before committing), the “require a confirmation token for write operations” pattern (the right answer for sensitive databases), and the “audit log every query” pattern (the right answer for compliance).
The schema-exploration flow (the one that prevents hallucinated columns)
The LLM is going to hallucinate column names. The LLM is going to guess that the user table has a name column when it actually has first_name and last_name. The LLM is going to guess that the orders table has a total column when it actually has total_cents. The LLM is going to guess that a relationship is a foreign key when it is actually a join through a junction table. The fix is to make the schema the first thing the LLM sees, and the fix is to make the schema the thing the LLM can navigate.
The flow is:
1. The LLM is told the database name and the available schemas. The ListResources request returns a list of schemas, and the LLM picks one (or all).
2. The LLM calls get_schema_info with the schema name. The tool returns the table names, column names, column types, primary keys, foreign keys, and indexes for the schema. The tool returns 100-500 tokens for a typical schema, and the tool is the lever that turns “I do not know the schema” into “I know the schema.”
3. The LLM calls get_schema_info with a specific table name if it needs more detail. The tool returns the column types, constraints, indexes, and foreign keys for the table. The tool is the lever that turns “I know the tables” into “I know the table.”
4. The LLM calls count_rows to see how big a table is. The tool returns a single integer. The tool is the lever that turns “I am about to query 10 million rows” into “I am about to query 100 rows.”
5. The LLM calls query_database with a WHERE clause and a LIMIT. The tool returns the rows (in TSV, with a pagination hint). The tool is the lever that turns “I queried everything” into “I queried the right slice.”
The five steps are the floor. There is also a get_sample_rows(table_name, limit=3) tool that returns a few sample rows (the right answer for “I need to see what the data actually looks like”), a get_foreign_keys(table_name) tool that returns the foreign keys (the right answer for “I need to know what this table joins to”), and a search_tables(query) tool that searches for tables whose name matches a pattern (the right answer for “I do not know which table has the data I want”).
The deploy step (the one that turns a script into a runtime)
A Postgres MCP server that runs on the developer’s laptop is a prototype. A Postgres MCP server that runs on a server, with a URL, with logs, with a restart policy, with environment variables for the database credentials, is a runtime. The deploy step is the difference between “I can demo this” and “I can use this in production.” The deploy step is also the step that most tutorials skip (the dev.to tutorial, the pgEdge tutorial, and the Punit blog all stop at “npm start” or “docker run” — they do not show the deploy).
The deploy step has four parts:
1. The Dockerfile. A multi-stage Dockerfile that builds the TypeScript (or Python) source, copies the compiled output to a slim runtime image (node:20-alpine, python:3.11-slim), exposes the port (3000, 8080, whatever), and starts the server. The pattern is the right answer for any service that needs to be deployed, the pattern is the lever that turns “I have a script” into “I have a container.”
2. The environment variables. The database connection string (DATABASE_URL=postgres://user:password@host:5432/dbname), the read-only flag (READ_ONLY=true), the statement timeout (STATEMENT_TIMEOUT=5000 for 5 seconds), the log level (LOG_LEVEL=info), and the port (PORT=3000). The pattern is the right answer for any service that needs configuration, the pattern is the lever that turns “the config is in the code” into “the config is in the environment.”
3. The deploy platform. A platform that runs the container, restarts on crash, exposes the URL, and shows the logs. The pattern is the right answer for any service that needs to be available 24/7, the pattern is the right answer for any service that other services depend on, and the pattern is the lever that turns “I have to start it myself” into “the platform runs it for me.”
4. The health check. A /health endpoint that returns 200 when the server is up and connected to the database. The pattern is the right answer for any service that the platform should restart on failure, the pattern is the right answer for any service that other services depend on, and the pattern is the lever that turns “I have to check if it is running” into “the platform checks for me.”
The four parts are the floor. There is also a TLS certificate (the right answer for any service that handles credentials), a domain name (the right answer for any service that humans use), a metrics endpoint (the right answer for any service that the team wants to monitor), and a rate limiter (the right answer for any service that the LLM is going to hammer with queries).
How this fits the rest of the deploy
A Postgres MCP server rarely lives in isolation. The server is usually part of a stack (a database, a deploy platform, an LLM client, a logging service) that runs the AI workflows the team ships. The platform that handles the server should make the rest of the stack feel like part of the same conversation.
The services layer is the part of the platform that runs the long-lived API the MCP server exposes. The database layer is the part that holds the data the MCP server queries. The environment variables are the part that holds the database credentials the MCP server needs. The error logs are the part that captures the queries the MCP server runs (and the ones it rejects). The MCP server is the bridge; the platform is what runs the bridge.
An AI workflow on a platform where the MCP server, the database, the storage, the logs, and the metrics are all in the same place is a workflow the team is going to be able to operate. An AI workflow on a platform where each piece is in a different console is a workflow the team is going to spend the first hour just opening the right tab.
For a team that wants to see the full cost of the project before it commits, the RunxBuild hosting calculator shows the line items together. The MCP server, the database, the storage, the worker, the bandwidth — each one is a separate number, and the team’s mental model for the platform is the sum of those numbers.
FAQ
What is a Postgres MCP server?
A Postgres MCP server is a small service that speaks the Model Context Protocol (MCP) and exposes a set of tools for interacting with a Postgres database: query_database (run a SQL query), get_schema_info (describe the tables and columns), count_rows (count the rows in a table), and other read-only tools. The server lets an LLM client (Claude Desktop, Claude Code, Cursor, etc.) safely query a real database, and the server is the simplest way to give an LLM a real database to work with.
How do I build a Postgres MCP server?
Install the MCP SDK (npm install @modelcontextprotocol/sdk pg dotenv), write a small server in TypeScript or Python that exposes the tools, connect to Postgres with pg (Node) or psycopg (Python), and add the server to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows). The server is 100-300 lines of code, the server runs locally, and the server is the prototype.
How do I make a Postgres MCP server read-only?
Three layers: (1) the database user has only the SELECT privilege on the relevant schemas, (2) the connection string includes default_transaction_read_only=on, and (3) the application layer rejects any non-SELECT statement before sending it to the database. The three layers are the lever that turns “the LLM can do anything” into “the LLM can only read.”
How do I deploy a Postgres MCP server?
Build a multi-stage Dockerfile that compiles the source and copies the output to a slim runtime image (node:20-alpine, python:3.11-slim), pass the database credentials and other config as environment variables, push the image to a container registry, and run it on a platform that supports health checks, restart policies, and persistent logs. The deploy step is the difference between “I have a prototype on my laptop” and “I have a runtime my team can use.”
Should I use JSON or TSV for query results?
TSV. TSV uses 30-40% fewer tokens than JSON for the same data, TSV is trivially easy for any LLM to parse (no special prompting required), and TSV is the format used by the major Postgres MCP servers (pgEdge, Anthropic’s reference implementation). The only case for JSON is when the LLM needs to do structured processing on the response (e.g. type checking, field access), and even then the structured processing is usually better done in code, not in the LLM.
How do I prevent the LLM from hallucinating column names?
Embed a get_schema_info tool that returns the table names, column names, column types, primary keys, and foreign keys. Tell the LLM (in the tool description) to call this tool first before running any query. The pattern is the lever that turns “I made up a column that does not exist” into “I asked the schema first, then queried.”
How do I handle malicious or untrusted MCP servers?
Do not connect an LLM to a database with an MCP server the developer does not trust. The MCP server has access to the database credentials, the MCP server can run any query the database user is allowed to run, and the MCP server can be replaced by a malicious actor who wants to steal data. The fix is to use a well-known MCP server (pgEdge, Anthropic’s reference, the official @modelcontextprotocol/server-postgres), audit the source code, and connect to a database with only the privileges the LLM needs (read-only, narrow schema, statement timeout).
How is a Postgres MCP server different from a regular database client?
A regular database client (psql, pgAdmin, DBeaver) is for humans — it shows the schema in a GUI, it lets the human write SQL, and it shows the results in a table. A Postgres MCP server is for LLMs — it exposes tools that the LLM can call, it returns the schema in a format the LLM can read, and it returns the results in a token-efficient format (TSV, with pagination). The two are complementary: the human uses the regular client to design the database, the LLM uses the MCP server to query the database.