Skip to content
Status

Identity and MCP governance / Give agents policy-restricted SQL access to Postgres

Give agents policy-restricted SQL access to Postgres

Deploy restricted-sql-mcp next to a private Postgres database and expose it through a Speakeasy tunnel, so agents can query an allowlisted set of tables without any inbound path to the database.

About restricted-sql-mcp

Restricted-sql-mcp is an open-source MCP server built and maintained by Speakeasy, licensed under the GNU Affero General Public License v3 (AGPL-3.0). It gives agents SQL access to Postgres and ClickHouse, limited by a table allowlist or per-column access annotations. It runs as a single Go binary or container, published at ghcr.io/speakeasy-api/restricted-sql-mcp. It also works on its own over stdio or loopback HTTP without a Speakeasy account. The built-in tunnel agent connects it to the Speakeasy AI Control Plane, which adds sign-in, access controls, and tool logs. Source, issues, and the full flag reference live in the GitHub repository.

Restricted-sql-mcp is an MCP server that gives agents SQL access to a Postgres database, limited to a table allowlist and a safe subset of SQL. It runs inside the private network beside the database. Its built-in tunnel agent dials out to Speakeasy, so people reach it through a hosted MCP endpoint with sign-in and access controls. The database gets no public IP, inbound firewall rule, Service, or Ingress.

This guide covers:

Every statement an agent sends is parsed with the real Postgres grammar, checked node by node against the allowlist, and rebuilt from the checked syntax tree. The database only receives that rebuilt statement, never the raw input. Unknown statement kinds, functions, types, and tables outside the allowlist are rejected with the offending node named, so the agent can correct its query.

Two independent layers protect the database:

  • The SQL guard, which rejects anything outside the allowlist before it reaches the database. Reads run inside BEGIN READ ONLY with a pinned statement_timeout and search_path.
  • Connection-level security, which provisions database roles that hold exactly the grants the allowlist needs and runs every tool call as those roles. A statement that slipped past the guard would still be refused by Postgres.

Requests reach the server like this:

  • The tunnel agent inside the restricted-sql-mcp process opens an outbound, TLS-protected WebSocket to the Speakeasy tunnel gateway.
  • An MCP client connects to the hosted endpoint. Speakeasy authenticates the caller and checks their access to the server and its tools.
  • Speakeasy forwards the request over the open tunnel. The tunnel agent passes it to the MCP listener on loopback and streams the response back.

The server exposes read tools (execute_sql_readonly, explain_query, list_schemas, list_objects, and get_object_details). Write tools are off by default.

  • A Postgres database, and a role that can create roles and grant privileges on the tables to expose.
  • A Kubernetes cluster that can reach the database and open outbound connections on port 443.
  • In Speakeasy, mcp:write on the project, which the Admin role holds by default, and a plan with tunneled MCP servers.
  • On the MCP page under MCP Gateway > MCP, click “Add new” and choose Reachable through a tunnel.
  • Name the source, for example postgres-readonly, and create it.
  • Save the tunnel key in a secret manager. It’s shown once and can’t be retrieved later.
  • Copy the gateway URL from the setup snippet. It’s the value for TUNNEL_GATEWAY_URL.

restricted-sql-mcp runs its own tunnel agent, so skip the separate agent container the setup snippet describes. See Add an internal MCP for the full tunnel setup.

With connection-level security, the database URL the server receives is a bootstrap connection. The server uses it only to create a read role (and a write role, when write tools are on), grant those roles exactly what the allowlist needs, and close the bootstrap connection. Tool calls then run as the provisioned roles. Each boot rotates the roles’ passwords and re-applies the grants, which also repairs any drift.

Create a bootstrap role that can do this and nothing more. It needs CREATEROLE and the privileges it hands out WITH GRANT OPTION. This example exposes every table in the app schema, read-only:

CREATE ROLE restricted_sql_mcp LOGIN CREATEROLE PASSWORD '<BOOTSTRAP_PASSWORD>';
GRANT CONNECT ON DATABASE app TO restricted_sql_mcp;
GRANT USAGE ON SCHEMA app TO restricted_sql_mcp WITH GRANT OPTION;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO restricted_sql_mcp WITH GRANT OPTION;
-- Cover tables created later, so a restart picks them up.
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT SELECT ON TABLES TO restricted_sql_mcp WITH GRANT OPTION;

The role owns nothing and can’t change the schema. To allow writes later, also grant INSERT, UPDATE, and DELETE on the write tables, and USAGE and SELECT on their sequences, all WITH GRANT OPTION.

Create the role in SQL on Cloud SQL

On Google Cloud SQL, users created through the Cloud SQL API or Terraform google_sql_user join cloudsqlsuperuser, which grants far more than the bootstrap role needs. Create the role with SQL, as above, so it holds only these privileges.

Store the bootstrap database URL and the tunnel settings in a Secret. In production, sync these from a secret manager rather than applying them by hand:

apiVersion: v1
kind: Secret
metadata:
name: restricted-sql-mcp
type: Opaque
stringData:
DATABASE_URL: postgres://restricted_sql_mcp:<BOOTSTRAP_PASSWORD>@db.internal:5432/app?sslmode=require
TUNNEL_GATEWAY_URL: <GATEWAY_URL_FROM_SETUP>
TUNNEL_KEY: <TUNNEL_KEY>

Deploy the server. The manifest has no Service or Ingress on purpose. MCP listens on loopback, where only the in-process tunnel agent reaches it, and a second listener serves only the health endpoints for the kubelet:

apiVersion: apps/v1
kind: Deployment
metadata:
name: restricted-sql-mcp
labels:
app: restricted-sql-mcp
spec:
# Provisioned role passwords rotate on every boot, and write transactions
# are held in process memory, so run exactly one replica.
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: restricted-sql-mcp
template:
metadata:
labels:
app: restricted-sql-mcp
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: restricted-sql-mcp
# Pin to a digest in production: ghcr.io/speakeasy-api/restricted-sql-mcp@sha256:...
image: ghcr.io/speakeasy-api/restricted-sql-mcp:0.2.4
args:
- --dialect=postgres
- --read-tables=app.*
- --connection-level-security
- --listen=127.0.0.1:8080
- --health-listen=0.0.0.0:8081
- --statement-timeout=30s
- --max-rows=1000
- --tunnel
- --tunnel-only
envFrom:
- secretRef:
name: restricted-sql-mcp
ports:
- name: health
containerPort: 8081
livenessProbe:
httpGet:
path: /healthz
port: health
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: health
periodSeconds: 10
failureThreshold: 3
startupProbe:
httpGet:
path: /readyz
port: health
periodSeconds: 5
failureThreshold: 24
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]

The flags that matter most:

FlagEffect
--read-tablesThe tables agents may read. Use schema.table names, or schema.* for every table in the schema when the server starts.
--connection-level-securityTreat DATABASE_URL as a bootstrap connection and run tool calls as provisioned least-privilege roles.
--tunnelStart the in-process tunnel agent, using TUNNEL_GATEWAY_URL and TUNNEL_KEY.
--tunnel-onlyRefuse any /mcp request that didn’t arrive through the in-process tunnel agent. Other processes and containers in the pod get 401.
--health-listenServe /healthz and /readyz on a second port for the kubelet. /mcp there returns 404.

A loopback bind alone isn’t isolation, because any container in the pod can reach 127.0.0.1. --tunnel-only closes that path. At startup the server generates a random token that never leaves process memory, attaches it to the URL the tunnel agent forwards to, and rejects any /mcp request without it.

Connecting through the Cloud SQL Auth Proxy

To reach Cloud SQL through the Auth Proxy, run it as a sidecar and point DATABASE_URL at 127.0.0.1. Turn off automatic IAM database authentication for this connection, so the bootstrap role and the provisioned roles can sign in with their passwords. Point the proxy at the primary instance, since the server writes roles and grants.

Wait for the rollout, then check readiness. The image is distroless and has no shell, so forward the health port instead of using kubectl exec:

Terminal window
kubectl rollout status deployment/restricted-sql-mcp --timeout=300s
kubectl port-forward deploy/restricted-sql-mcp 8081:8081 &
curl -s http://127.0.0.1:8081/readyz

A ready server answers 200 with "tunnel":"connected" in its body. The tunnel state is reported but doesn’t affect readiness. The logs show the provisioned roles and the tables they were granted:

Terminal window
kubectl logs deploy/restricted-sql-mcp | grep "postgres roles provisioned"

In Speakeasy, open the linked MCP server and connect from its Inspect tab. Then run an end-to-end check from an MCP client:

  • Call list_objects with the app schema and confirm the allowlisted tables appear.
  • Run execute_sql_readonly with a SELECT on an allowlisted table.
  • Run a SELECT on a table outside the allowlist, or a DROP TABLE, and confirm the server rejects it and names the reason.

Keep the server’s visibility Private. Speakeasy checks every caller’s access before forwarding a request through the tunnel, and each person signs in as themselves, so tool logs record who ran each query.

Queries and their results appear in Tool logs, and guardrails apply to them like any other tool call.

The server expands schema.* and grants privileges when it starts. Tables added later aren’t covered until the pod restarts:

Terminal window
kubectl rollout restart deployment/restricted-sql-mcp

Readiness refreshes column metadata, so added, dropped, and renamed columns on existing tables are picked up without a restart. From version 0.2.4, a dropped or renamed table affects only queries that use it, not the readiness of the whole server. Earlier versions could stay unready after a migration dropped an allowlisted table, so upgrade before running destructive migrations.

Don't let this server block other deploys

A GitOps tool that waits for every resource to become healthy, such as Argo CD, stalls when this Deployment stays unready. Deploy restricted-sql-mcp as its own application, or exclude it from the health checks that gate other workloads.

To let agents change data, add the write tools and a write allowlist:

args:
- --write-tables=app.orders
- --dangerously-add-write-tools

Write tools allow only INSERT, UPDATE, and DELETE, inside a transaction the agent explicitly commits or rolls back. DDL is always rejected. Idle transactions roll back after --txn-idle-timeout (default two minutes), and every transaction rolls back after --txn-max-lifetime (default five minutes).

--databases takes a regular expression and serves matching databases on the same Postgres server next to the default one, for example per-branch preview databases:

args:
- --databases=^app_preview_pr_[0-9]+$

Tools then take an optional database argument, and list_databases lists what the server serves. Each database gets its own provisioned roles, which the server drops when the database disappears. The bootstrap role needs the same privileges in every served database.

Pin the image by digest and upgrade by changing the digest. To rotate the tunnel key, create a new key on the MCP source in Speakeasy, update the Secret, and restart the pod. The tunnel key lets its holder serve responses to anyone using this endpoint, so store it with the same care as the database credentials.

/readyz returns 503 with a JSON reason. Common causes:

  • connection-level security not provisioned: ... means the bootstrap role is missing a privilege. The message names the statement that failed. Grant the missing privilege WITH GRANT OPTION. The server retries on every probe, so no restart is needed.
  • A ping failure means the pod can’t reach the database. Check DATABASE_URL, network policy, and any Auth Proxy sidecar.

If readiness passes but MCP clients can’t connect:

  • "tunnel":"connecting" that never changes to connected means the outbound connection is failing. Check that the pod can reach the gateway URL on port 443 and that TUNNEL_KEY matches the source.
  • A query rejected with a table name means the table is outside the allowlist, or was created after the server started. Restart the pod to pick up new tables.
  • Client requests to /mcp from inside the pod get 401 by design when --tunnel-only is set.

Set --log-level=debug to log the raw and rebuilt SQL, the tables each statement touched, and rejection reasons. The database URL is never logged.