An agent that can read your database is genuinely useful. “Why did signups drop on Tuesday?” “Find the orders stuck in pending for more than an hour.” “What’s the schema of the events table?” These are questions the model answers in seconds if it can run a query — and slowly, badly, or never if it can’t. So you go looking for how to connect it, and every guide says the same thing: put your database connection string in the agent’s environment, or in an MCP server’s config, and you’re done.
You are done, in the sense that it works. But you’ve also made a security decision, whether you meant to or not. The moment an agent can touch a real database, the interesting question isn’t “can it write SQL” — of course it can. It’s who holds the credential, and what can that credential do.
The naive path, and why it’s a trap
A database connection string is a bearer credential. postgres://app:[email protected]:5432/prod isn’t a pointer to access — it is access. Whoever holds those bytes can connect as that user, with everything that user can do. And the user in your app’s connection string can almost always do a lot more than SELECT: INSERT, UPDATE, DELETE, DROP TABLE, TRUNCATE. You wanted the agent to answer a question about last week’s signups; you handed it the ability to delete them.
Three things make this worse than it looks:
- You can’t un-share it. Once a connection string has passed through a model’s context — an env var, a config blob, a tool call you didn’t read — you can’t prove it didn’t end up in a transcript or a log. The only honest remediation is to rotate the database password everywhere it’s used.
- Prompt injection reaches it. A poisoned row, a booby-trapped document, a tool result from elsewhere in the session — any of these can talk a model into running a query you never asked for, or into exfiltrating the very credential it was handed. The agent’s context is not a safe place to keep a secret.
- There’s no scope and no expiry. A raw connection string doesn’t come with a “read-only” mode or a clock. If the account can write, the agent can write. If the credential never expires, neither does the access.
The rule of thumb is the one we keep coming back to for SSH keys: if revoking a credential means remembering it exists and rotating it everywhere, you’ll put it off, and it will outlive the experiment.
What good looks like: the client holds the credential
Flip the model. The agent should never hold the connection string at all.
- The client is the custodian. A trusted program holds the database credential and does the connecting. The agent doesn’t get the secret — it gets the ability to ask that program to run a query and hand back rows.
- Read-only is the default, not an option you remember to set. Write and schema-changing statements are refused unless you’ve explicitly granted that specific connection more. A confused or hijacked model gets
SELECT, notDROP. - Scope is per connection. Access to your analytics replica is a separate decision from access to your production primary. Enabling one doesn’t enable the other.
- Revocation is a toggle. Because nothing was ever shared, turning access off costs nothing and leaves no copies behind.
This is the same custody thesis we apply to servers — in shrinking an agent’s blast radius and in what an MCP server actually is. Databases are just the sharpest version, because the blast radius of a bad write is measured in rows.
How Termalin’s Data client works
Termalin is a cross-platform SSH client that recently grew a Data client — a database client in the desktop app and in the web cabinet at termal.in/account. You add a connection the way you’d add a server: pick the engine, give it a host and credentials (or, for SQLite, a file), optionally name a default database. Then you get a workspace — a tree of databases and tables on the left, a SQL editor in the middle, results below. Click through the schema, write a query, read the rows. None of that requires an agent; it’s a normal, useful database client.
The Data client speaks a spread of engines, not just Postgres:
- PostgreSQL, MySQL / MariaDB, and SQLite
- Microsoft SQL Server and ClickHouse
- MongoDB / DocumentDB
- Redis
- Elasticsearch / OpenSearch
- Cassandra / ScyllaDB
- Neo4j and InfluxDB
- RabbitMQ
- Cloudflare D1
Each speaks its own query language — SQL for the relational engines and ClickHouse, Cypher for Neo4j, a Redis command, a collection {filter} for MongoDB, and so on.
Direct, or through a Connector. A database on your own network, you reach directly. A database behind a firewall — the common case in production — you reach through a Termalin Connector: it opens an SSH tunnel to a box that can see the database, forwards a loopback port through it, and points the driver at that port. The database never faces the internet, and you never open a port to it.
Where the agent comes in
Termalin ships a built-in MCP server, and once you turn agent data access on it exposes four database tools:
data_list # your saved connections: id, name, engine
data_tables # tables / collections / measurements, with row counts
data_schema # one table's columns: name, type, nullable, primary key
data_query # run a query, get rows back (default cap 200)
Register the local server and it drives the Data client inside your running, unlocked app, so the connection details stay with the client:
claude mcp add termalin -- <path>/termalin-mcp
The agent can now discover connections, walk a schema, and run queries. What it can’t do is anything you didn’t allow, because two gates sit in front of it:
- Agent data access is off by default. A single master switch in Settings → Agent. Until you flip it, the data tools return “database access for agents is off,” full stop.
- Every connection is read-only for the agent by default. Even with the master switch on, each query is checked before it runs. On the SQL-family engines only read verbs pass —
SELECT,SHOW,DESCRIBE,EXPLAIN,WITH,PRAGMA. On Redis, only read commands. On MongoDB, an aggregation with$outor$mergeis blocked. On Neo4j,CREATE,MERGE,DELETE,SET,DROPare blocked. A write only goes through if you’ve raised that specific connection’s agent policy to full access — a per-connection decision, not a global one.
So the property you wanted holds. The agent can answer “why did signups drop on Tuesday?” by reading the rows. It cannot delete Tuesday.
What “read-only by default” actually gets you, honestly
It’s a query gate, and it’s worth being precise about its edges. It stops the obvious destructive path — the accidental or injected DELETE, DROP, UPDATE, the aggregation that writes back out. What it is not is a substitute for a properly scoped database role: a SELECT can still read data you’d rather it didn’t. For anything sensitive, do both — point the Data connection at a read replica or a least-privilege role and leave the agent read-only. The client-side gate catches the mistakes; the database-side grant sets the ceiling. And because the agent works through your running app, no connection string is written anywhere it can read, and the whole thing switches off with one toggle.
Where to start
Start read-only, with one connection you don’t mind an agent poking at — a staging database, an analytics replica, a copy. Leave the per-connection policy where it defaults: read-only. Ask the agent something real — describe a table, count the stuck orders, explain the slow query — and watch how it forms its SQL before you consider granting anything more. If you later want it to write, raise exactly one connection to full access, and know precisely what you traded.
That’s the quiet payoff of the custodian model, whether it’s shells or schemas: you expand one connection at a time, because no step you take is one you can’t take back.
Termalin is a free, cross-platform SSH and database client with a built-in MCP server, a key custodian and per-connection agent policy — download it, or read how it handles credentials safely.