How I built the query studio for natural language interactions with your database.

The inspiration for Query Studio came from a small SQL application I had already built. It could run a query, display a table, and export results. I wanted to make that workspace accessible through a conversation: ask a question in everyday language, see the SQL behind the answer, and follow up without starting over.

AI made it easier to explore the idea and iterate on the implementation. The decisions that interested me were how to give the assistant useful tools, control what those tools could execute, and make its answers inspectable. A database assistant becomes much more useful when the user can reliably ask the questions in natural langugage.

Query Studio connects to a PostgreSQL/SQL database and discovers its structure. It combines schema-aware SQL generation, validated read-only execution, a user query, and a result table in one workspace. This article follows those decisions, including the boundaries that still need care.

The tools used by the MCP

I used MCP, the Model Context Protocol, to expose the application’s query capabilities through a defined interface. Each tool has an input contract, an output contract, and a specific job:

  • construct_sql — Uses the database schema, the request, and retained context to generate SQL. It validates the completed statement and records the accepted query.
  • execute_query — Rechecks the SQL and runs exactly the accepted statement through the database layer. This tool makes no AI-model call.
  • summarize_results — Optionally sends the executed SQL and fetched rows to the AI model to produce an explanation. It runs after database execution.

The MCP client and server communicate inside the same application process, so this implementation does not require a separate MCP service to deploy. LangChain manages the agent’s decisions about the available tools. MCP defines how those capabilities are called, the application supplies their behavior and safeguards.

Each turn has an internal request ID linking its generated SQL, execution, and result. That keeps one turn’s work separate from another’s. The request state is released when the turn finishes, while the shared MCP connection remains available.

How the MCP uses the tools

Before the assistant can write a useful query, the application needs to understand the database. It connects using the configured database connection and fetches schema information: accessible tables, columns, data types, and structural relationships. This metadata provides a map of what can be queried; discovering it does not require loading the tables’ business records.

The schema is cached to avoid repeating those discovery queries for every question. When the database structure changes, the schema explorer’s refresh action reloads the metadata. SQL generation can then use the updated structure.

Meaningful table and field names help the LLM connect a question to that structure. Names such as customer_id, outstanding_balance, and payment_due_date provide clues about the intended query. Natural-language descriptions attached to tables could add valuable business context too. The current implementation does not yet include those descriptions in the model context.

For a compatible PostgreSQL database, changing the connection configuration lets the application discover its schema without hand-writing a new table map for that dataset. The current driver, schema discovery, and SQL checks are PostgreSQL-specific. Supporting other SQL engines would require changes to those parts, and database-specific business definitions may still need guidance.

Consider the request ‘Show the top five customers by outstanding balance.’ The agent first decides whether fresh data is needed. If it is, construct_sql loads the cached schema, fetching it if necessary, and builds the SQL-generation context. A simplified example looks like this:

System instructions: Generate a single read-only PostgreSQL query using the supplied schema. Retained conversation: Relevant earlier questions, answers, SQL, and result context, if available. Request context: Discovered tables and columns, plus the current user request: “Show the top five customers by outstanding balance.”

These are supplied as structured model messages. Instructions describe the task, while the schema and question supply the information to work with. That distinction helps explain how the assistant moves from a sentence to a query.

The accepted SQL then goes to execute_query. PostgreSQL returns the matching rows, and only afterward can the server invoke summarize_results when explanation is enabled. Generation and explanation are separate AI operations, with database execution between them. The summary tool is called by the server after execution rather than offered as another choice in the agent’s tool-selection loop.

SQL construction, execution, and optional explanation happen in separate stages. Requests that need no fresh database data can receive a direct response.

SQL construction, execution, and optional explanation happen in separate stages. Requests that need no fresh database data can receive a direct response.

Making summarization an explicit choice

At the runner level, summarization is off by default when the summarizeResults option is omitted. It can be enabled by setting that configuration option to true. There is an important distinction in the currently deployed app: the config explicitly enables it.

Summarization sends fetched records to the model for explanation. For confidential data, I would recommend enabling it only with a private AI deployment approved to process those records, or another explicitly approved provider arrangement. A private deployment still needs appropriate access and data-handling controls.

Within the three tools, summarize_results is the one that directly makes a model call containing the newly executed result for explanation. execute_query performs the database read without calling a model. However, it would be inaccurate to say that turning summaries off keeps all fetched data away from the model: retained conversation history includes rows, and that history can be supplied to later agent and SQL-generation calls. A strict schema-only AI mode would also need to exclude rows from those context paths.

If an enabled summary fails after a successful query, the table remains useful. The application returns it with a warning instead of discarding the result.

Generated SQL still has to be validated

A generated query is a proposal until it passes the application’s validation checks. Query Studio parses the completed SQL, accepts one SELECT-based statement, checks its referenced tables against the discovered schema, and rejects writes, deletes, schema changes, and other disallowed forms.

The validation also covers less obvious cases such as a write inside a common table expression (CTE), multiple statements in one request, SELECT INTO, and row-locking clauses. Checking whether the text begins with SELECT would leave too much unexamined.

Execution repeats the checks and requires the incoming SQL to match the statement recorded by construct_sql after normalization. The agent cannot generate one query and substitute a different one at the execution step.

The database should enforce its own boundary as well. For a private deployment, the connection should use a dedicated read-only database user, with SELECT access limited to the intended tables or views and no write or schema-management privileges. That role must be configured by the database administrator, the application does not automatically provision it.

Irrespective of the role, the app executes accepted queries inside a PostgreSQL read-only transaction. Application validation, a suitably restricted database user, and transaction-level enforcement provide distinct layers of protection. The first checks what the assistant proposes; the database controls what the connection is permitted to do.

Execution also has a ten-second statement timeout, a three-second lock timeout, and a maximum of 1,000 returned rows. An extra-row check detects truncation. ‘1,000 rows loaded’ describes the returned snapshot, not the total number of matches, and a row cap alone does not guarantee a cheap query.

Application checks precede read-only database execution, with timeouts and a bounded result set. A restricted database role adds a deployment-level safeguard.

Application checks precede read-only database execution, with timeouts and a bounded result set. A restricted database role adds a deployment-level safeguard.

These controls constrain execution. They cannot prove that the query answers the intended business question. A valid query can still use the wrong date range or count accounts when the user meant customers. The visible SQL gives the user something concrete to inspect. The public demonstration uses synthetic data and has no login; private deployments also need authentication and appropriate user-level access policies.

Keeping the agent from calling indefinitely

An agent can keep reconsidering a task or repeatedly request tools without making progress. I used LangChain’s middleware mechanism to enforce a lifecycle around that behavior. The application’s custom middleware currently permits at most six model calls in the agent loop, then stops the turn with an error if another call is attempted.

The middleware also ends the agent loop once query execution has produced a result and stops further iteration after a tool failure. Repeated generation, execution, or summary operations within the same turn reuse their existing work instead of silently starting it again.

The six-call limit bounds the agent loop; SQL generation and optional summarization make additional model calls outside that count. This prevents an unbounded planning loop, but it does not eliminate hallucinations or guarantee a correct answer. Though, during the test runs across multiple scenarios no hallucinations were observed but depending on complexity of your database and the model of choice there is a finite possibility of inaccurate results. Query validation and result inspection address different parts of that problem.

Beautifying the SQL

The SQL is a visible part of the answer. Users can inspect selected fields, filters, joins, and ordering instead of relying only on the explanation. CodeMirror provides syntax highlighting, and sql-formatter supplies PostgreSQL-aware indentation and line breaks for the display.

Streaming makes formatting less straightforward. A formatter needs enough meaningful SQL to parse a formattable block; an unfinished keyword or half-written expression may not be usable yet. A progressive formatter should therefore try only when a coherent block has arrived and keep the original text when formatting fails.

The current implementation takes the simpler approach: CodeMirror shows the incoming SQL while it streams, and sql-formatter runs after generation has completed successfully. Progressive block-by-block formatting would be a further refinement. Formatting affects the preview only, never the SQL sent for execution.

The table is part of that inspectable result too. Users can sort loaded rows, page through them, resize columns, and export a CSV of the snapshot. Those actions do not silently fetch additional data or rerun the query. The table implementation already existed in this project without any AI integration. Table was implemented using react-table to leverage the performance benefits and get out of the box sorting and other features.

Supporting the multiple turns

After ‘Show the top five customers by balance,’ a follow-up such as ‘Explain that ranking’ can use retained context. ‘Only active customer, refresh the ranking’ or ‘show the home branch for these customers and find the top customers at those branches’ needs a new query. ‘Show the best ones’ may need clarification about what best means. A useful conversation makes room for all three responses.

The browser supplies retained conversation history to each fresh server turn. Currently the app server does not retain history. A saved pair includes the question and an answer context containing the explanation, SQL, metadata, and loaded rows. This lets a later request preserve earlier intent without treating the previous request ID as persistent memory.

History is limited to 40 messages, or up to 20 question-and-answer pairs, and 120,000 characters. The browser drops the oldest complete pairs when either budget is exceeded. It does not trim individual result rows to make a pair smaller. Even the newest pair can be removed if it cannot fit.

A new turn receives bounded browser-held history and can explain an earlier result, ask for clarification, or request fresh data.

A new turn receives bounded browser-held history and can explain an earlier result, ask for clarification, or request fresh data.

Visible conversation and model context are separate. A table can remain on screen after its turn has left the assistant’s history. Retained results are also snapshots: a top-five result cannot establish a full-dataset total, and an earlier balance does not establish the current one. Fresh facts and unseen rows require a new query.

Possible optimizations and improvements

One useful improvement would be adding natural-language table and column descriptions when that metadata is available. Names and types describe structure; descriptions can explain business meaning, preferred joins, units, or what a particular status represents. That would give the model more context than identifiers alone.

A second improvement is selective context trimming for longer conversations. A smaller language model could assess which earlier turns are relevant to the current question and retain a compact account of the necessary filters, definitions, and intent. That selection would need its own checks so it does not discard an important constraint or turn an uncertain detail into a fact. Any rows sent to that smaller model would need the same data-handling review.

Token-aware budgeting would complement the current message and character limits. I would also add evaluation questions with known results, especially for joins, date ranges, rankings, and follow-ups. Those checks would test whether queries capture the intended meaning, beyond being syntactically valid and permitted to run.

Possible future extensions and applications

The same MCP structure could be extended into a Grafana dashboard assistant. Connect to the database, retrieve its schema and any useful description metadata, and let the model propose dashboard queries without sending business records during that design step. The user could review and select the queries, then an additional MCP tool could create or update a Grafana dashboard through its API.

That would need Grafana authentication, panel and data-source configuration, and a deliberate review step before posting changes. The current project does not implement that integration. Separating query proposals from dashboard publication would keep each responsibility clear.

Background triggers could make this workflow more proactive. For example, a schema refresh could detect a new column and suggest a dashboard panel. A database-side check could detect new field values or another custom condition and trigger a proposed data collection or report. A schema-only check can detect structural changes; detecting new values requires reading data, so that trigger’s permissions and model payload would need separate design.

Scheduled reporting is another possible extension: run approved read-only queries for observability, analytics, or regular operational summaries. A scheduler could collect the results, with an optional explanation when the chosen deployment is permitted to process them. Scheduling, delivery, and dashboard-writing tools would be additions to the current server.

These extensions build on the same foundation: discover the database, construct a query with context, validate it, and keep execution within explicit boundaries. Query Studio makes those stages visible enough for the user to inspect an answer and decide what to do next.

See the live deployment here

Explore the Query Studio project

For a deeper look at the architecture, MCP tool flow, query safeguards, and setup, read the detailed Query Studio documentation on GitHub.