duckdb-analytics-mcp
The server provides a read-only, context-efficient analytics interface over a DuckDB warehouse, optimized for LLMs. It enables safe dataset exploration, schema understanding, column profiling, query validation, and execution.
Capabilities
list_datasets: Lists all tables with row counts, grain, and business-critical caveats (e.g., filter out cancelled orders for revenue).describe_table: Returns full schema details—columns, types, null rates, join keys, sample values, and semantic definitions—in one efficient call.profile_column: Deep-dives into a column’s distribution, outliers, coverage gaps, and formatting issues, adapting to numeric, date, or text types.explain: Shows DuckDB’s query plan and estimated row count without execution, helping avoid expensive mistakes.query: Executes a single read-onlySELECTwith automatic row/time caps, explicit truncation disclosure, and support for markdown/JSON output—encourages aggregation to save tokens.
Key Features
Strict Safety: Read-only mode, external access disabled, AST-based SQL parsing rejects all non-SELECT or dangerous operations, plus watchdog-enforced timeouts.
Semantic Layer: Surfaces business rules and caveats directly in tool outputs to prevent logically incorrect queries.
Token Efficiency: Returns compact markdown by default, advises aggregation over raw row fetching, and provides full truncation clarity.
Actionable Errors: Clear messages with concrete recovery guidance to lead the LLM toward successful interactions.
Provides read-only analytics over DuckDB databases, offering tools to list datasets, describe tables, profile columns, explain query plans, and run SELECT queries with row and time caps.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@duckdb-analytics-mcpDescribe the orders table with caveats."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
duckdb-analytics-mcp
A read-only analytics MCP server over DuckDB, built around one idea: an LLM's scarce resource is context, not compute.
Most database MCP servers expose a single run_sql(query) tool and hand the raw
result back as JSON. That fails in two predictable ways. It blows the context
window — a 50,000-row result set cannot be read by a model, and truncating it
silently is worse than erroring. And it produces confidently wrong answers,
because a model that only sees column names has no way to know that cancelled
orders still have line items attached to them.
This server is an attempt to fix both.
list_datasets → what tables exist, and what is dangerous about each
describe_table → columns, types, null rates, sample values, join keys
profile_column → distribution, outliers, coverage gaps, dirty values
explain → estimated cost, without executing
query → a single read-only SELECT, row-capped and time-cappedQuickstart
git clone https://github.com/<you>/duckdb-analytics-mcp
cd duckdb-analytics-mcp
pip install -e ".[dev]"
python scripts/generate_data.py # builds the sample warehouse (~150k rows)
pytest # 105 tests, ~10s
python scripts/verify.py # end-to-end over the MCP protocol
python bench/token_report.py # reproduces the numbers belowThen register it with any MCP client:
{
"mcpServers": {
"warehouse": {
"command": "python",
"args": ["-m", "duckdb_analytics_mcp"],
"env": { "WAREHOUSE_DATA_DIR": "/absolute/path/to/duckdb-analytics-mcp/data" }
}
}
}The sample dataset ships in the repo, so the server is queryable the moment it
starts. Point WAREHOUSE_DATA_DIR at a directory of your own Parquet files and
it will build a warehouse from those instead.
Related MCP server: sqldb-mcp-server
The context argument, measured
From bench/token_report.py, counted with tiktoken cl100k_base:
what | naive format | tokens | this server | tokens | change |
50 rows × 5 cols | JSON objects | 2,468 | markdown table | 962 | −61% |
50 rows × 5 cols | padded markdown | 1,233 | unpadded markdown | 962 | −22% |
"revenue by status" | 200 raw rows | 2,848 | aggregate in SQL | 86 | −97% |
query plan | DuckDB's default | 629 | compact tree | 49 | −92% |
|
| 1,738 |
| 410 | −76% |
truncation footer |
| ~2× query time | fetch | within noise | free |
Three decisions come out of that table:
JSON repeats every key on every row. Twenty rows of six columns means 120 redundant key tokens. A markdown table names each column once.
Alignment padding is pure cost. Pretty-printed tables pad cells with spaces so the pipes line up. A model does not need the pipes to line up.
The cheapest result set is the one you never return. The largest saving on
that table is not a formatting trick — it is query's tool description telling
the model to aggregate in SQL instead of pulling rows. 2,848 tokens of raw rows
answer the question worse than 86 tokens of GROUP BY.
Truncation is disclosed, always
A silently truncated result is the worst failure mode available to a database tool, because the model will average the visible 20 rows and report it as the answer. Every truncated result says so, and says how big the real answer was:
order_id | status
--- | ---
100001 | returned
100002 | cancelled
100003 | completed
_Showing 3 of 50,000 matching rows (no LIMIT was given, so one was applied).
**The remaining 49,997 rows are not shown** -- do not treat this sample as the
full result set. Add your own LIMIT/OFFSET to page through, or aggregate in SQL
instead of pulling rows._Truncation is detected by fetching one row past the cap: if that row comes
back, there is more. It is the same execution, so the cost stays inside
measurement noise. Knowing the exact total is a different matter — it needs a
second full execution wrapped in count(*), which measured +82% to +174%
across runs, i.e. roughly double. That is now opt-in via exact_total: true
instead of being charged on every truncated query.
The same rule applies one dimension over. A cell value too long to print is shortened, and the footer says so:
_200 rows. **3 cell values were shortened** to 60 characters (longest
original: 4,182 characters) -- treat those values as incomplete, and select a
substring or an aggregate in SQL if you need the full content._A cut JSON blob that looks like a whole one is the same failure as a cut result set that looks complete.
The semantic layer
This is the part that changes answers rather than costs.
data/datasets.yaml carries, per table, the business rules that determine
whether a query is correct — as opposed to whether it runs. They are
surfaced by list_datasets and describe_table, so the model sees them before
it writes SQL rather than after:
- table: orders
caveats:
- "REVENUE RULE: exclude status IN ('cancelled','returned'). Cancelled and
returned orders still have order_items rows attached, so an unfiltered
join overstates revenue by 13.6%."
- "There are zero orders between 2025-03-10 and 2025-03-16 (a checkout
outage, not missing data)."None of those are SQL errors. A query that ignores them runs fine, returns a
number, and the number is wrong by 13.6%. That is the failure mode a schema
dump cannot prevent, and it is the reason describe_table exists instead of
letting the model read information_schema itself.
The claims in that file are load-bearing, so the test suite asserts them
against the data. test_revenue_caveat_is_arithmetically_true recomputes the
13.6% figure, and tests/test_catalog.py checks every table, column, key and
backticked name the YAML mentions against the live schema — a caveat naming a
column that was renamed is worse than no caveat, because it reads as
authoritative.
profile_column finds the same class of problem empirically, without being
told:
# customers.country
value | count | share
--- | --- | ---
US | 1,269 | 31.7%
...
us | 52 | 1.3%
⚠ **Case/format collisions:** 'us' (2 variants). Grouping on this column raw
will split what is really one value. Normalise with `lower()` or `upper()`.On a date column it reports gaps in coverage; on a numeric column it reports Tukey outliers and tells you when the mean has been dragged off the median.
Security model
The threat is not a malicious user — it is a model that has read a prompt injection in a data cell and is now trying to write files. Two independent layers, neither trusting the other:
1. The connection cannot write. DuckDB is opened read_only=True with
enable_external_access=false. No httpfs, no read_csv on arbitrary paths,
no ATTACH. Even a query that defeated the parser would find nothing to do.
2. Every statement is parsed before it runs. guards.py uses sqlglot to
build an AST and rejects anything that is not a single SELECT. Parsing, not
regex — a regex guard is defeated by SELECT/**/1;DROP TABLE t and false-fires
on a customer named "Delete Co.". There is a test for both.
Blocked: every DDL/DML statement, multi-statement queries, filesystem and
network functions, and — via an allowlist on the FROM clause — any data
source that is not an existing table, CTA, subquery, or safe generator.
Writing that test suite found two real bypasses in my own guard:
read_parquetwas not caught.sqlglotparses some functions into dedicated classes (exp.ReadParquet) and the rest intoexp.Anonymous; the guard only checked the latter. Fixed by checkingsql_names()on everyexp.Func.SELECT * FROM 'https://host/data.parquet'was not caught. DuckDB treats a quoted string inFROMas a file to scan. Fixed by rejecting path-shaped identifiers.
Both are in tests/test_guards.py as regression tests.
Runaway queries are the other half. DuckDB has no statement_timeout, so
engine.py runs a watchdog thread that calls connection.interrupt() at the
deadline. explain exists so the model can check first — it catches an
accidental cross join at 5.1 billion estimated rows without executing anything.
Errors are recovery instructions
An error a model cannot act on is a dead end. Every rejection names the path forward:
Error: DROP is not allowed. This server is read-only and exposes exactly one
way to read data: a single SELECT statement. To explore what is available,
call `list_datasets` for the table list or `describe_table` for columns.
Error: No table named 'ordrs'. Available tables: customers, order_items,
orders, products. Call `list_datasets` for descriptions of each.
Error: Query exceeded the 15s limit and was cancelled. Narrow it with a WHERE
clause, aggregate instead of selecting raw rows, or call `explain` first to see
the estimated scan size.test_rejection_messages_name_a_recovery_path asserts this for every blocked
statement in the suite.
Evaluations
MCP has no real testing story, so EVALS.md holds ten natural-language
questions with verified answers, and evals/evaluation.xml holds the same set
in the harness format. Seven of the ten are designed so that the obvious query
returns a plausible wrong answer — they test whether the semantic layer is
actually doing its job:
question | naive answer | correct answer |
2025 revenue | 69,713,408 | 61,207,080 |
US customers | 1,269 | 1,321 |
typical unit price | 689.70 (mean) | 424.29 (median) |
Answers were computed directly against the warehouse, not by asking a model.
The dataset is seeded, so they are stable across machines, and
scripts/verify.py re-derives all ten through the live query tool and fails
if EVALS.md or evaluation.xml has drifted from them.
Layout
src/duckdb_analytics_mcp/
server.py five tools, input schemas, error mapping
guards.py the SQL security boundary
engine.py execution, timeouts, compact EXPLAIN
profiling.py describe_table / profile_column
catalog.py the semantic layer loader
formatting.py token-efficient rendering
warehouse.py read-only connection, warehouse build
data/ sample Parquet + datasets.yaml
tests/ 105 unit tests
scripts/ data generator + end-to-end protocol verification
bench/ the token measurements aboveConfiguration is by environment variable: WAREHOUSE_DATA_DIR,
WAREHOUSE_DB_PATH, WAREHOUSE_CATALOG_PATH, WAREHOUSE_MAX_ROWS (200),
WAREHOUSE_TIMEOUT_S (15), WAREHOUSE_MAX_CELL_CHARS (60).
Notes and limitations
Tool parameters are flat (
sql=...) rather than wrapped in a Pydantic model. Both work; a wrapper emits a$defs/$refschema with an extra nesting level, which costs tokens in every tool listing.list_datasetsruns onecount(*)per table. Fine at four tables, wasteful at four hundred — it should readduckdb_tables()instead.Exact
COUNT(DISTINCT)is budgeted in cells (rows × columns), not rows, because that is what drives the cost — a 50k-row table with 150 columns is far more expensive than a 4M-row table with three. Above the budget it falls back to HyperLogLog and says so.describe_tablebatches its aggregates 25 columns at a time and caps output at 60 columns by default. A single monolithic query over a wide table has no partial result: exceed the deadline and you get nothing. It took 21.7s on a 150-column table before this change, against a 15s server timeout — the tool used to kill itself on exactly the tables where a schema description helps most. Now 3.9s.Reads get one DuckDB cursor per query rather than sharing one behind a lock. Throughput barely moves (DuckDB already parallelises a single query), but a cheap call no longer queues behind an expensive scan: 3ms instead of waiting out the whole slow query.
The semantic layer is hand-written YAML. The obvious next step is generating it from a dbt
manifest.json, where these definitions usually already live.Single-database, stdio only. Multi-tenant use would need streamable HTTP, per-tenant connections, and auth.
License
MIT
Available Tools
5 toolsdescribe_tableARead-onlyIdempotent
Describe one table: columns, types, null rates, distinct counts, examples.
Call this before writing a query against an unfamiliar table. It answers "what can I filter on, and what will be NULL" in a single round trip, which a raw information_schema dump does not.
Args: table (str): Table name, case-insensitive (e.g. 'orders').
Returns: str: Markdown containing: - header with row count and grain - a table of (column, type, null%, distinct, range, examples) - column definitions from the semantic layer - declared join keys - caveats that affect correctness
On failure: "Error: No table named 'x'. Available tables: ..."Examples: - Use when: "What columns does orders have?" - Use when: a query failed with an unknown-column error. - Don't use when: you need the distribution of one column (use profile_column instead).
| Name | Required | Description | Default |
|---|---|---|---|
| table | Yes | Table name, e.g. 'orders'. Case-insensitive. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the readOnlyHint/idempotentHint annotations, the description discloses the exact return format (Markdown with row count, columns, semantic definitions, join keys, caveats) and error behavior, including the error message with available tables. This is rich behavioral context.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with clear sections (main description, Args, Returns, Examples). Every sentence serves a purpose, and it is front-loaded with the core functionality. Length is justified by the depth of useful guidance.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's simplicity (one parameter, read-only, with output schema), the description is complete: it explains what it does, when to use it, what it returns, and error handling. No critical information is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema description already covers the parameter ('Table name, e.g. 'orders'. Case-insensitive.'). The description's Args section repeats the same information without adding new meaning, so baseline 3 for high schema coverage is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb and resource: 'Describe one table: columns, types, null rates, distinct counts, examples.' It clearly differentiates from siblings by noting when not to use it and recommending profile_column for single-column distribution.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicit guidance is provided: 'Call this before writing a query against an unfamiliar table' with concrete use cases and a non-use case that names an alternative tool (profile_column). This clearly explains when to use this tool versus siblings.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
explainARead-onlyIdempotent
Show the query plan and estimated row count WITHOUT executing the query.
Use this when a query might be expensive, or after one has timed out. The estimate comes from DuckDB's optimizer, so it is an estimate, not a guarantee -- but it is enough to catch an accidental cross join before it runs for fifteen seconds.
Args: sql (str): The SELECT to plan. It is not executed.
Returns: str: Markdown containing the estimated cardinality, a verdict on whether the query is worth running, and the physical plan.
On failure: "Error: <guard message>" for non-SELECT input.Examples: - Use when: a previous query hit the timeout. - Use when: joining two large tables for the first time. - Don't use when: you just want the answer (call query directly).
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | One SELECT statement in DuckDB SQL. CTEs, joins, window functions and UNION are supported. Anything that writes (INSERT, UPDATE, DELETE, CREATE, DROP, COPY, ATTACH, PRAGMA) is rejected, as are multiple statements separated by semicolons. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond annotations (readOnlyHint=true, idempotentHint=true), the description explains that estimates come from DuckDB's optimizer and are not guarantees. It also discloses the failure guard for non-SELECT input and the markdown return format, adding meaningful behavioral context beyond what annotations provide.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Every sentence serves a purpose: purpose, usage guidance, parameter note, return value, failure handling, and examples. The sectioned format with bolded examples is well-structured and front-loaded with the key point (no execution). No fluff.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
This is a near-complete specification: purpose, usage, return format, failure behavior, and guard conditions. The single parameter is fully documented in the schema, and the description clearly explains the output type (Markdown string) even without the output schema being shown.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema already describes the sql parameter in great detail (100% coverage), including supported constructs and rejected statements. The 'Args' line in the description restates what the schema says without adding new semantics. Baseline 3 is appropriate since schema does the heavy lifting.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb ('Show') and resource ('query plan') and immediately clarifies the tool does not execute the query. This cleanly distinguishes it from sibling tools like 'query' and other data operations. The scope is unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It explicitly states when to use the tool (expensive queries, timeouts) and when not to use it ('you just want the answer (call query directly)'). It names the alternative sibling tool directly, fully satisfying the when/when-not/alternatives criterion.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_datasetsARead-onlyIdempotent
List every table in the warehouse with its row count, grain, and caveats.
Start here. The output is deliberately small -- one line per table plus the business rules that change query correctness -- so it is cheap to call before anything else.
Returns: str: Markdown containing: - a table of (table, rows, grain, description) - a "Before you write SQL" section listing every caveat in the warehouse, each prefixed with its table name
On failure: "Error: <message>".Examples: - Use when: "What data do I have access to?" - Use when: starting any analysis, before describe_table. - Don't use when: you already know the table and need its columns (use describe_table instead).
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already mark this as read-only/idempotent, and the description adds valuable behavioral context: the output is deliberately small and cheap, it returns a Markdown table plus caveats, and it notes failure returns 'Error: <message>'. This goes well beyond the schema.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is concise yet complete: a clear one-sentence summary, a 'Start here' hint, a structured Returns section, and use/non-use examples. Every sentence adds value without redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a zero-parameter discovery tool, the description covers purpose, when to use, output format, failure behavior, and even the caveats section. It is fully sufficient for an agent to invoke correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool has zero parameters, so there are no parameter semantics to explain. Per the rubric, a baseline of 4 applies; the description correctly omits parameter details since none exist.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource: 'List every table in the warehouse with its row count, grain, and caveats.' It clearly distinguishes from siblings by contrasting with describe_table ('Don't use when... need its columns').
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives explicit when-to-use guidance: 'Start here' and examples for use ('What data do I have access to?') and non-use ('use describe_table instead'). It names the alternative tool, making the decision clear.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
profile_columnARead-onlyIdempotent
Profile one column: distribution, outliers, coverage gaps, dirty values.
The profile adapts to the column type:
numeric: min/p25/median/mean/p75/p95/max/stddev, plus a Tukey outlier count and the largest values when the distribution is skewed
date/timestamp: range, distinct days, and the largest gaps in coverage
text/boolean: top values with shares, plus a warning when values differ only by case or formatting
Args: table (str): Table name, case-insensitive. column (str): Column name, case-insensitive.
Returns: str: Markdown profile, ending with the column's definition and any caveats that mention it.
On failure: "Error: Table 'orders' has no column 'x'. Columns: ..."Examples: - Use when: "Is unit_price skewed? Should I use mean or median?" - Use when: "Are there missing days in order_date?" - Use when: a GROUP BY returned more groups than expected. - Don't use when: you want the whole schema (use describe_table).
| Name | Required | Description | Default |
|---|---|---|---|
| table | Yes | Table name, e.g. 'orders'. Case-insensitive. | |
| column | Yes | Column name, e.g. 'unit_price'. Case-insensitive. |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, idempotentHint=true, and destructiveHint=false. The description adds substantial behavioral context beyond these: it explains that the profile adapts to column type (numeric/date/text) with specific statistics and warnings, describes the return format as Markdown, and even includes an error message example for invalid input. This goes far beyond annotation data.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with clear sections (summary, type-specific behavior, Args, Returns, Examples), uses bullet lists for readability, and front-loads the core purpose in the first line. Every sentence earns its place, providing rich detail without fluff or redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a tool with only two parameters, the description is thorough: it covers all column type behaviors, return format, error handling, and typical use cases. It also leverages an output schema (though not shown) and annotations, making it fully contextual for an agent to select and invoke correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already provides 100% description coverage for both parameters (table and column), including examples and case-insensitivity. The description's Args section essentially repeats this information without adding new meaning. Baseline is 3 due to high schema coverage; the description adds no extra parameter semantics.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with 'Profile one column: distribution, outliers, coverage gaps, dirty values', which is a specific verb+resource combination that clearly states what the tool does. It further distinguishes itself from sibling tools like describe_table by explicitly saying 'Don't use when: you want the whole schema (use describe_table)' and by focusing on single-column profiling.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit 'Use when' scenarios with concrete example questions ('Is unit_price skewed?', 'Are there missing days in order_date?') and a non-example ('a GROUP BY returned more groups than expected'). It also gives a clear exclusion ('Don't use when: you want the whole schema') and names the alternative (describe_table).
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
queryARead-onlyIdempotent
Run one read-only SELECT against the warehouse and return the rows.
Enforcement, in order: the statement is parsed and rejected unless it is a single SELECT; a LIMIT is injected if absent and lowered if it exceeds the server cap; execution is cancelled if it exceeds the time limit. When the result is truncated, the true total is counted and reported -- the output never implies it is complete when it is not.
Prefer aggregating in SQL over selecting raw rows. SELECT count(*), avg(x)
costs a handful of tokens; SELECT * costs hundreds and usually answers
less.
Args: sql (str): One SELECT statement in DuckDB SQL. max_rows (Optional[int]): Per-call row cap, clamped to the server cap (default 200). response_format (ResponseFormat): 'markdown' (default) or 'json'.
Returns: str: For 'markdown', an unpadded markdown table followed by a row-count footer that discloses truncation. For 'json', an object: { "columns": [str], "rows": [[Any]], "row_count": int, # rows returned "total_rows": int|null, # true total when truncated "truncated": bool, "elapsed_ms": float }
On failure: "Error: <message>" naming the recovery path.Examples: - Use when: "What was revenue by month in 2025?" -> aggregate in SQL. - Use when: "Show me 10 example rows from orders." - Don't use when: you do not yet know the column names (call describe_table first -- it is cheaper than a failed query).
Error Handling: - Non-SELECT statements, multiple statements, and filesystem functions are rejected before execution. - Queries exceeding the time limit are cancelled, not left running. - Unknown columns return DuckDB's message, which names the candidates.
| Name | Required | Description | Default |
|---|---|---|---|
| sql | Yes | One SELECT statement in DuckDB SQL. CTEs, joins, window functions and UNION are supported. Anything that writes (INSERT, UPDATE, DELETE, CREATE, DROP, COPY, ATTACH, PRAGMA) is rejected, as are multiple statements separated by semicolons. | |
| max_rows | No | Row cap for this call. Defaults to the server cap (WAREHOUSE_MAX_ROWS, 200 by default) and can never exceed it. | |
| response_format | No | 'markdown' (default) is compact and cheap to read. 'json' returns columns and rows verbatim for programmatic use, at roughly 2-3x the token cost. | markdown |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Beyond the readOnlyHint and idempotentHint annotations, the description discloses important enforcement behaviors: non-SELECT statements are rejected, LIMIT is injected or clamped, time limits cancel execution, and truncation is explicitly reported with total_counts. This gives the agent a detailed model of what will happen and why.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Although long, the description is dense and well-structured, with sections for enforcement, usage guidance, arguments, return values, examples, and error handling. Every section adds practical information and the key purpose and safety characteristics are front-loaded in the first sentence.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description fully covers the tool's complexity: it explains safety enforcement, truncation semantics, error recovery paths, concrete examples for appropriate use, and output formats. The presence of an output schema does not reduce the need for this behavioral context, and the description delivers it comprehensively.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema already has 100% parameter coverage with detailed descriptions, so the baseline is 3. The description adds extra meaning by explaining the enforcement behavior of max_rows (clamped and lowered), the token-cost tradeoff of response_format, and the concrete return shapes associated with each format, going beyond the schema's field-level definitions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The opening sentence clearly states the tool runs one read-only SELECT against the warehouse and returns rows, specifying both the verb and resource. It distinguishes itself from sibling metadata tools like describe_table and list_datasets by being the SQL query tool for actual data retrieval.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit when-to-use and when-not-to-use guidance, including direct examples like aggregating in SQL versus selecting raw rows and calling describe_table first when column names are unknown. It also says what not to use the tool for, making it easy for an agent to choose among siblings.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
5 tool updates
v0.1.0- First observed
describe_table - First observed
explain - First observed
list_datasets - First observed
profile_column - First observed
query
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: list_datasets for table inventory, describe_table for schema details, query for running SQL, profile_column for column-level analysis, and explain for query planning. There is no overlap or ambiguity between them.
All tool names follow a consistent lowercase_with_underscores convention, with a verb-led pattern (list_, describe_, query, profile_, explain). Though 'query' and 'explain' are single verbs, the naming is predictable and uniform.
With 5 tools, the server is well-scoped for an analytics warehouse. Each tool fills a necessary niche without redundancy, making the set feel lean but complete.
The tool surface covers the full analytical workflow: discover datasets, understand schema, profile data, run queries, and plan expensive queries. There are no obvious missing operations for the stated purpose of read-only analytics.
Maintenance
Related MCP Connectors
Query, join, profile, clean and convert CSV/JSON/Parquet with server-side DuckDB over MCP.
Query your warehouse or a CSV with Claude/ChatGPT over MCP, governed by table-level ACL + audit.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Hosted MCP server for AI-driven data ops. Create apps, manage schemas, and CRUD structured data.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceModel Context Protocol (MCP) server that gives AI assistants a safe, correct data-analyst capability over business metrics - without raw SQL improvisation.-
- AlicenseAqualityCmaintenanceA read-only MCP server that exposes SQL database access to LLMs, supporting multiple database types, compact columnar results, pagination, and file export.612 npmMIT
- AlicenseNot gradedqualityDmaintenanceA read-only MCP server that enables LLMs to safely explore and query any SQLite database via natural language. It exposes tools for listing tables, describing schemas, and executing SELECT/WITH queries with built-in safety guards like write prevention and row limits.MIT
- AlicenseNot gradedqualityDmaintenanceMCP server for SQL analytics on DuckDB and MotherDuck databases, enabling AI assistants and IDEs to query data via natural language.1MIT