An LLM that can write SQL against a production database is a liability. It will eventually write a DELETE, or a join that scans a billion rows, or a query that reads a table it was never meant to see. Prompt instructions do not change that, because the model has no way to enforce them.
The alternative is to let the model ask questions but never write queries. The MCP server does the translating, and the model only ever sees the answers.
Tools, not a query box
Each capability is a named tool with a typed parameter list: monthly revenue by product line, order counts by region, inventory movement for one SKU. The model picks a tool and fills in parameters. It cannot compose arbitrary SQL, so the blast radius of a confused model is one malformed parameter.
The parameters are bound, not interpolated. A tool that takes a date range receives two dates and the query uses placeholders. There is no string concatenation anywhere in the path, which removes the injection surface rather than filtering it.
#[tool(name = "revenue_by_product_line")]
async fn revenue_by_product_line(
&self,
from: NaiveDate,
to: NaiveDate,
) -> Result<Json<RevenueReport>> {
sqlx::query_as::<_, RevenueRow>(REVENUE_BY_LINE)
.bind(from)
.bind(to)
.fetch_all(&self.pool)
.await
.map(Into::into)
.map_err(Into::into)
}
Pre-authorized data, zero trust in the client
The second half of the problem is scope. A tool should return the block of data the caller is allowed to see, computed by the server, not a raw table the model then filters. That means aggregation and row limits live inside the tool, and the response is already safe when it leaves.
This is the part that is easy to get wrong in a demo. Returning a full table and telling the model to summarize it works until someone asks a question the model answers from a column it should not have had.
Two transports, one binary
The server speaks STDIO and HTTP from the same binary. STDIO is what a desktop client uses when it launches the process; HTTP is what a shared deployment uses. Keeping both in one build means the tool definitions cannot drift between the local and hosted paths.
The lesson worth keeping is the boundary. The model is good at deciding which question to ask. It is not the right component to decide how that question reaches the database.



