Query with SQL

Let the LLM read the app’s data by writing a SELECT statement of its own, joining, grouping, aggregating and ordering as the question needs. It reads exactly the tables and columns the spec’s sqlQuery tags expose, and nothing else. Answers come back a page at a time.

Ingredient Family: llmQueryToolIngredients

Outline Display: Allow LLM to query the sqlQuery-tagged tables ({{toolName}}): {{toolDescription}}
Rows per page: {{pageSize}}
Requires approval: {{requiresApproval}}{{advisory:
Not enforced by the server: {{advisory}}}}

Details

What this tool may read is not configured here: it is what the spec’s sqlQuery tags expose. A table is readable only when its own tags: carry sqlQuery, and a column of it only when the column’s tags: do too – deny by default at both levels, so adding the tool to a module opens nothing until the schema says what it opens. A query naming anything else is refused, with a message saying what it may read instead, and a module running this tool while nothing is tagged is a spec error. The tags are the whole grant, and they describe the schema rather than any one module: every module holding a Query with SQL tool reads the same exposure, and there are no row filters and no per-caller narrowing on this tool yet, so tag a column only where every caller of every such module may read every row of it. A foreign key comes through as the referenced key’s own value, so two exposed tables join on it whether or not anything exposed the rest of the table it points at. The model sends one sql argument: a single SELECT statement, in the same dialect as the app’s own queries. It is parsed, held to the exposure above, checked and re-printed by the server before Postgres sees it, so what runs is a statement the server built rather than text the model wrote. Statements that are not SELECTs – INSERT, UPDATE, DELETE, DDL – do not parse, and neither do multiple statements. The checking is the whole statement, by the same rules and in the same words as a query written into the spec: every expression types, WHERE and HAVING and each JOIN condition are conditions, a query that aggregates groups by everything else it names, aggregates stay out of WHERE, GROUP BY and ORDER BY, and no two tables or output columns go out under one name. Answers are paged, and pages are read by offset, so a query that could answer with more than one page has to order its rows: one that could and does not is refused as bad arguments, with a message saying to add an ORDER BY, ideally on a key column. A query that cannot fill a page needs none – one whose own LIMIT is at most the rows per page, or an aggregate with no GROUP BY, which is a single row. An answer with rows behind it carries a nextCursor; send it back as the cursor argument, with no sql, for the next page, and keep going until no cursor comes back. Send exactly one of sql and cursor. A LIMIT the query writes for itself is honored and is never paged past. The companion tool <name>_schema answers in one call with everything needed to write a query: a dialect reference for the SQL this tool accepts, and every table and column it may read, with their types. Call it before writing the first query rather than guessing at the language or at the names. It takes no arguments and is not paged. A refused query, a parse error and a type error all come back as errors the model can read and correct: a name outside the exposure is a refusal, and a statement that does not parse or does not type is bad arguments. A statement the server refuses after all that checking is the app malfunctioning rather than a tool result, and fails the request as any other broken query would; so does one that runs longer than a single request may spend on it. Each executed query is audited once per table it reads, subqueries included, with the SQL as its arguments.

Settings

Required

  • toolDescription : constant value (type: text; required, non-nullable)
    A description of what this set of tables holds and what the LLM should use it to answer, to help it decide when to query rather than reach for another tool

Optional

  • toolName : constant value (type: text; required, non-nullable)
    The name the LLM uses to call this tool; the schema companion is advertised as this name with _schema appended. Defaults to the entry’s own name:

  • toolTitle : constant value (type: text; optional)
    The human-readable name an MCP client shows for this tool (MCP’s title), never read by a model. Omitted, it is derived from the wire name: search_open_orders becomes “Search Open Orders”. Two tools in one module cannot share a title, derived or authored.

  • advisory : constant value (type: text; optional)
    A rule the server does NOT enforce, sent to the model as guidance under an explicit ‘not enforced’ header; use only when no deterministic form (Require, parameter constraints, allowedRecipients…) exists. The checker warns and the dashboard shows it as not guaranteed.

  • requiresApproval : constant value (type: bool; required, non-nullable)
    Require the user to approve each invocation of this tool before it runs. Only honored inside a Chat concept, where the agent pauses mid-turn and shows Approve/Reject buttons; rejecting tells the LLM the call was denied and ends the turn. Setting it true on a tool used by any other agent (e.g. a row-action AskAnLLM triggered by a button) is rejected by the NectryCore typechecker – except under MCP Server, which only warns. When unset, the tool is gated exactly when its steps make it anything but read-only – the same reading its MCP category comes from, so a tool that annotates itself read-only is not gated – and only where a pause is honored: an unattended surface never invents a gate it cannot keep.

  • pageSize : constant value (type: integer; required, non-nullable)
    Rows a single call may return, from 1 to 200. The server’s choice, not the model’s: a query asking for more is paged and hands back a cursor, and one that could ask for more without ordering its rows is refused until it does. A LIMIT the query writes for itself is honored as written.