Skip to content
The Cloud Guy
ATC-006 Agentic AI Guide

Giving a coding agent production database access: the boundary is the grant, not the code

A read-only MCP server for a production MySQL database on RDS. Why the database grant is the real boundary, what else to layer on, and the Azure SQL version.

Published
Reading time
6 min
On this page
  1. Start with one question: what stops it from writing?
  2. The grant is the boundary
  3. Everything else is defence in depth
  4. Customer data lands in the agent’s context
  5. Make the right thing easy
  6. Keep it local
  7. What I’d add on AWS
  8. The same setup on Azure SQL
  9. A checklist before you connect an agent to production

When you debug a data problem with a coding agent, there’s a lot of copying. The agent writes a query, you paste it into a database client, you copy the results back, it writes the next one. Twenty rounds later you’ve become a very slow API.

The obvious fix is to give the agent a database tool. It’s also a frightening one if the database is production. This is how I set that up for a small SaaS business running MySQL on Amazon RDS, and the one rule that makes it safe enough to use.

Start with one question: what stops it from writing?

Not the prompt. Telling an agent “only run SELECT statements” is a request, not a control.

Not the tool’s code either, at least not on its own. You can write a careful SQL filter, and you should. But SQL has corners that a filter can miss, and a filter is code that someone can change.

The thing that stops it from writing is the database account it connects with. So the design starts there, and everything else is decoration on top of it.

The grant is the boundary

The tool connects as a dedicated MySQL user with exactly these privileges:

  • SELECT and SHOW VIEW on the application’s schema
  • SHOW_ROUTINE, so it can read stored procedure definitions (it’s a global privilege, so it covers every schema on the server, which is fine for reading code but worth knowing)
  • SELECT on performance_schema, for looking into slow queries

Just as important is what it doesn’t have: EXECUTE. The database has stored procedures, and some of them write. An account that can call procedures can write through them, even with no UPDATE privilege of its own. Leaving out EXECUTE closes that side door.

Before writing any tool code, I tested the grant against production:

  • An UPDATE fails with MySQL error 1142, “UPDATE command denied”.
  • A CALL to a writing procedure fails with error 1370, “execute command denied”.

Those two checks are now part of the tool’s self-test. It runs on every install and deliberately skips its own filter to attempt a write and a procedure call. Both must be refused by MySQL. If either one ever succeeds, the instructions say to stop and fix the grant.

The test I apply to the whole design: if you deleted every line of the tool’s safety code, could the account still not write? Here, it couldn’t.

Everything else is defence in depth

With the boundary in the database, the tool’s own checks have a different job. They don’t have to be perfect. Their job is to fail fast, explain clearly, and keep an honest mistake from reaching the server.

Coding agent defence in depth: fail fast, fail clearly Guard one read-only statement Driver multipleStatements: false Ceilings 500 rows, 30 s by default Connection cap 4 of the account’s 5 TLS certificate verified Log every query, kept locally The database grant SELECT only. No EXECUTE. The boundary. Production data
Remove every dashed layer and the account still can't write. That's the test.

The guard accepts exactly one statement per call, and it must start with SELECT, SHOW, DESCRIBE, EXPLAIN or WITH. Before it checks for write keywords, it strips out string literals, quoted identifiers and comments. That way a customer whose surname is “Updates”, or a column named UPDATE_DATE, doesn’t trip it. It also rejects a few MySQL-specific tricks:

  • Executable comments. MySQL runs the contents of a /*! … */ comment, so a filter that ignores comments can be walked straight past. The guard rejects them outright.
  • SELECT … INTO OUTFILE, which writes a file on the database server.
  • Locking reads. SELECT … FOR SHARE needs nothing more than the SELECT privilege and doesn’t change data, but it can block the application’s real writes while it holds its locks. On production, that’s an outage you caused by reading. (FOR UPDATE is refused by the grant anyway, and the guard rejects both.)

The driver is opened with multiple statements disabled, so even a guard bug can’t send two statements in one round trip.

Ceilings cap every query at 500 rows and 30 seconds by default, with hard maximums of 5,000 rows and two minutes. For SELECT statements the time limit is also set on the server, with MySQL’s MAX_EXECUTION_TIME, so a runaway join is stopped by the database rather than abandoned by the client. (It doesn’t apply to SHOW or EXPLAIN, which are cheap anyway.)

A connection cap keeps the tool to four connections, and the account itself is limited to five in MySQL. However busy the agent gets, it can’t use up the connections the application needs.

TLS is verified. Encryption alone doesn’t prove you’re talking to your database. The tool pins Amazon’s RDS certificate bundle and explicitly rejects any server certificate that doesn’t chain to it, so nothing in the network path can impersonate the database.

Every query is logged to a local file, successes and failures alike, so you can always reconstruct what the agent looked at.

Customer data lands in the agent’s context

A read-only tool can still leak. Whatever a query returns goes straight into the agent’s context. Several tables hold customer names, emails, phone numbers, addresses and dates of birth.

So the tool redacts known personal-data columns by default. The agent can pass an include_pii flag when customer identity really is the point of the question. The documentation is honest about the limit: redaction matches the returned column name, so SELECT full_name AS x gets through. It reduces accidental exposure. It isn’t a control, and it doesn’t pretend to be one.

Make the right thing easy

Two tools exist only to save the agent from guessing. describe_table returns the full table definition, including indexes and partitioning, and describe_routine returns a stored procedure’s body. Several tables here have more than 100 columns, and an agent that guesses column names burns turns and your patience.

Keep it local

This server runs on a laptop, next to the coding agent, and it is deliberately never deployed. The database password comes from the macOS Keychain at startup, not from a config file, and the agent’s configuration contains no credentials at all. Network access depends on the laptop’s IP address being allowed into the database’s security group. When your home IP changes, queries fail with a timeout, not a clear error. Check the security group before debugging anything else.

What I’d add on AWS

  • IAM database authentication. RDS for MySQL can authenticate with short-lived tokens generated from IAM credentials (each one is valid for 15 minutes), which removes the stored password entirely.
  • A read replica. Pointing the agent at a replica takes its queries off the primary altogether, so even a heavy query can’t slow the application.

The same setup on Azure SQL

The principle carries over directly, and Azure SQL makes two of the layers stronger.

  • Identity instead of a password. Use Microsoft Entra authentication, and give the agent’s tool its own Entra identity, mapped to a contained database user.
  • The grant. Add that user to the built-in db_datareader role and explicitly deny EXECUTE. As with MySQL, procedures are the side door.
  • A read-only replica for free, on the right tier. Premium, Business Critical and Hyperscale databases have read scale-out. Add ApplicationIntent=ReadOnly to the connection string and the agent lands on a read-only replica, at no extra cost on Premium and Business Critical. The General Purpose tier doesn’t include it.
  • Redaction in the database. Dynamic Data Masking hides designated columns in query results for anyone without the UNMASK permission, and the stored data isn’t changed. Never grant UNMASK to the agent’s user. Personal data is then masked by the server, not by your tool, and aliasing a column no longer gets around it. It’s still not a hard boundary: Microsoft notes that someone who can write ad hoc queries can infer masked values, for example with range filters in a WHERE clause. Like the MySQL redaction, it prevents accidental exposure, so pair it with auditing.
  • Keep the other layers. The guard, ceilings, connection cap and query log belong in the tool on any database.

A checklist before you connect an agent to production

  • A dedicated database user for the agent, with read privileges only and no EXECUTE
  • A self-test that tries to write, bypassing your own filter, and must fail
  • One statement per call, no locking reads, no file writes, no executable comments
  • Row, time and connection limits the agent can’t raise past a hard cap
  • Verified TLS, credentials out of config files, and every query logged
  • Personal data redacted by default, with masking in the database where you can get it
  • A read replica if you have one

None of this makes the agent smarter. It makes the worst case boring, which is what you want from anything pointed at production.


Sources: MySQL privileges, IAM database authentication for RDS, Azure SQL read scale-out, Azure SQL Dynamic Data Masking.

  • mcp
  • coding-agents
  • security
  • mysql
  • rds
  • azure-sql

Related

Have a project in mind?

Let's see if I can help.

Send a message