A read-only analytics MCP server over DuckDB, with a semantic layer and token-efficient output.
Strong tool definitions with excellent descriptions (50-200 char range), comprehensive parameter schemas, and clear use-case guidance. All 5 tools have detailed docstrings explaining WHAT, WHEN, and WHY. Parameters are well-typed with min/max constraints. Output schemas documented in descriptions. Tool annotations (readOnlyHint, idempotentHint) present. Main gaps: no explicit error recovery guidance in descriptions, and output structure could be more formally specified in schema form rather than prose.
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'). max_columns (Optional[int]): Column ceiling for this call, default 60. Returns: str: Markdown containing: - header with row count, column 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).
Estimate the cost of a query without executing it. Use this before running an expensive query. The output is a compact query plan showing the estimated row count at each step, plus a summary of which tables will be scanned and how many rows each scan will touch. Args: sql (str): One SELECT statement in DuckDB SQL. Returns: str: Markdown containing: - a compact query plan tree with estimated cardinality at each step - a summary of table scans and their estimated row counts - the estimated maximum rows that will flow through the query On failure: "Error: <message>". Examples: - Use when: "Will this query be fast?" - Use when: a query timed out and you want to see why. - Don't use when: you want to see the actual results (use query instead).
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).
Output schemas documented only in prose descriptions, not as formal JSON Schema. LLMs cannot parse markdown descriptions to extract field types and structure for downstream tool chaining.
Error handling descriptions lack recovery guidance. E.g., 'On failure: Error: <message>' does not tell the LLM what to do next (retry, call a different tool, ask user). Should include actionable next steps.
No dry-run or confirmation mechanism for the query tool, which executes arbitrary SQL. Although read-only, a runaway query can consume resources. Consider adding a 'dry_run' boolean parameter to call explain() first.
Inferred effective spec: <=2025-11-25.
| Scored | Grade | Overall | Spec posture | Rubric |
|---|---|---|---|---|
| 2026-09-23 | C | 63 | <=2025-11-25 | v2 |
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).
Execute a read-only SELECT and return the results. This is the only tool that returns raw data. It enforces a row limit (default 200) and a time limit (default 15 seconds) to prevent runaway queries from blocking the LLM. If your query hits the row limit, the response will say so and suggest aggregating in SQL instead of pulling raw rows. Args: sql (str): One SELECT statement in DuckDB SQL. limit (int): Row ceiling for this query (default 200, max 10000). format (str): Output format: 'markdown' (default) or 'json'. Returns: str: Markdown table or JSON array of objects, plus a footer disclosing whether the result was truncated and how to get the rest. On failure: "Error: <message>". Examples: - Use when: you need to see actual data values. - Use when: a describe_table or profile_column result raised a question. - Don't use when: you want to know if a query is expensive (use explain).
Parameter descriptions include example values (e.g., 'e.g. orders', 'e.g. unit_price'). LLMs tend to reuse examples literally. Replace with formal constraints (enums, patterns, min/max) where applicable.
Tool composition: all 5 tools are read-only discovery/analysis tools. No mutation tools (create_table, insert_data, etc.). This is intentional for a read-only analytics server, but limits agent autonomy for data preparation workflows.