An agent needs an account's name and ID. The customer table also holds email addresses it has no reason to see. Switching the MCP server to read-only can stop writes, but it doesn't decide which of those columns belongs in the answer.

Give the server its own Postgres login, grant access to the tables or views the task needs, and test a query you expect to fail. In our three-server lab, restricted grants blocked a private-table read through DBHub, Google MCP Toolbox, and Crystal DBA. Broader grants let the same read through all three, including the two servers configured to reject writes.

The example below keeps customer email in a private table and exposes a view with just the ID and name. It also shows a separate row-level security policy for tenant data. Datatape publishes this tutorial and sells hosted database MCP infrastructure; Datatape wasn't included in the lab.

The SQL excerpts come from the downloadable fixture, whose init.sql creates the example schemas and tables. They aren't a standalone migration for an empty database. The reproduction commands are below; use the same access pattern with your own object names and credentials.

Grant access through a restricted role

Start with a dedicated login so the MCP connection doesn't inherit the privileges of an application owner. Here, tenant_a connects to Postgres and inherits read access from lab_select:

CREATE ROLE lab_select NOLOGIN;
CREATE ROLE tenant_a LOGIN PASSWORD 'fixture-only'
  NOSUPERUSER NOBYPASSRLS;
GRANT lab_select TO tenant_a;

GRANT USAGE ON SCHEMA allowed TO lab_select;
GRANT SELECT ON ALL TABLES IN SCHEMA allowed TO lab_select;

USAGE lets the role resolve objects in the schema. SELECT permits reading the granted tables and views. Neither statement grants access to the separate private schema. PostgreSQL documents these separately in its privilege reference.

This example grants SELECT on all existing tables in a deliberately small allowed schema. If that schema contains unrelated tables, grant individual objects instead. Future tables also need an explicit privilege policy; GRANT ... ON ALL TABLES isn't a promise about objects created later.

Role membership matters. Removing a direct grant from a login won't help if another role still supplies it. The lab's database-grants.txt records membership, object grants, and the RLS policy. Check the effective login too:

SELECT current_user, session_user,
       current_setting('search_path') AS search_path;

An owner, superuser, or role with broader inherited privileges is a different configuration from the restricted login described here.

Test the forbidden query directly

First check that the connection can do its job. This query should return Acme:

SELECT id, name FROM allowed.accounts WHERE id = 1;

Then try the fully qualified private table:

SELECT * FROM private.customers;

The restricted login receives permission denied for schema private. The broader fixture login receives the synthetic customer record. Check both the response and the identity used for the call.

Changing search_path doesn't revoke that access. It changes how unqualified names resolve. In the fixture, the broader login's search path contains only allowed, but private.customers still resolves when named explicitly. Discovery filters can reduce irrelevant schema context without enforcing an object allowlist.

Expose a projection when some columns are private

You don't have to grant access to the whole customer table to make an account lookup work. This view exposes the ID and name while leaving email out:

CREATE VIEW allowed.customer_summary AS
SELECT id, name FROM private.customers;

The fixture creates the view before granting SELECT. If you create a view after running the earlier grant, grant it explicitly:

GRANT SELECT ON allowed.customer_summary TO lab_select;

With SELECT on the view and no access to the private schema, the restricted login can query allowed.customer_summary but cannot query the underlying table. Asking for email from the view fails because that column isn't present.

This uses Postgres's default view behavior, where access to underlying relations is checked using the view owner's permissions. A security_invoker view behaves differently. Review the CREATE VIEW reference before changing ownership or view options. This view controls which columns are exposed. Tenant filtering needs its own policy.

Bind tenant access to a trusted identity

Now suppose the agent should only see one tenant's accounts. The fixture demonstrates that on a separate table, using the database login as the tenant identity:

ALTER TABLE allowed.tenant_accounts ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_identity ON allowed.tenant_accounts
USING (tenant_id = current_user);

The tenant_a login sees its own row. Adding WHERE tenant_id = 'tenant_b' returns no rows. The broader login also sees no rows: it has table privileges, but its name matches neither tenant. Table owners, superusers, and BYPASSRLS roles have different behavior, as described in the Postgres RLS documentation.

A shared MCP database login won't acquire a different current_user merely because a caller supplies a tenant_id parameter. If your application uses one login for all tenants, design how trusted caller identity reaches the query and policy. A tenant value chosen by the agent is a filter, not proof that the caller may access that tenant.

Check writes, timeouts, and the next request

Keep read-only execution as an additional control where the server supports it. In the lab, Postgres grants denied INSERT and UPDATE through generic Toolbox SQL; DBHub and Crystal DBA also rejected those statements at the server layer with broader grants. The comparison includes the tested versions and Crystal DBA advisory.

The fixture also gives each login a bounded statement duration:

ALTER ROLE tenant_a SET statement_timeout = '1500ms';

The runner holds an exclusive lock, issues a read, and then repeats an ordinary read after the lock is released. Inspect timeout-lock and recovery in the evidence. A package can apply its own timeout settings, so read the observed error rather than assuming the role setting was the layer that stopped execution.

Start with the runnable fixture

Download the Postgres MCP lab, extract it, and open a terminal in that directory. You need Docker and Python 3.11 or later:

python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python run.py --output ./results
.venv/bin/python verify.py ./results

The runner creates an isolated database, applies init.sql, runs the MCP probes, and removes its containers. Its passwords and records are public fixture data. The SQL above comes from that fixture. Use an empty output directory for each run.

After configuring your connection, test an allowed record, a qualified private table, the restricted columns, another tenant, and a mutation. Keep those requests with the grants used during the test. For queries that recur, the next step is to expose a named, reviewed operation so the agent doesn't need to submit SQL at all.