/ 2min

Giving an LLM access to ERP data without giving it SQL

How an MCP server in Rust exposes analytics to LLM clients through pre-authorized tools, and why parameter binding beats a query-writing model.

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.

More posts

2 min

Failover without breaking the stream

Moving a request to a healthy provider is easy. Moving it after the first token has already reached the client is the hard part.

  • Rust
  • Streaming
  • Reliability
Read article

2 min

One route, five windows

How a single SvelteKit route became a workspace, a dock, a desktop-locked board, and two on-demand windows, all reading the same local database.

  • Tauri
  • Svelte
  • Desktop
Read article

2 min

An agent that edits files should ask first

Buffering every write until a human approves the plan, and why the approval gate belongs in the runtime rather than the prompt.

  • AI
  • Architecture
  • Permissions
Read article