All nine tools registered by drill-mcp (see drill_mcp/server.py). Every tool raises a single exception type, ToolError, to the MCP client — never a raw traceback, never an unrelated Python exception. ToolError‘s message is the only thing a model sees; where the underlying failure is Drill’s own error text, that text is passed through (and truncated at 2000 characters — see client_rest.py_error_text).
Five of the nine tools (list_storage_plugins, cluster_status, list_profiles, get_profile, cancel_query) are management tools: they require the REST backend. On the JDBC backend they all raise the same ToolError:
'<method>' needs a REST connection to Drill; the JDBC backend does not expose management endpoints
This is because JdbcClient (drill_mcp/client_jdbc.py) simply does not implement storage_plugins, cluster_status, profiles, profile, or cancel_query — there is no fallback or emulation attempted.
run_queryrun_query(sql: str, max_rows: int | None = None) -> dict
Runs exactly one SQL statement against Drill and returns its rows.
Parameters
| Name | Type | Optional | Notes |
|---|---|---|---|
sql | string | no | Exactly one SQL statement. Anything that doesn't parse as one statement is rejected — see “Safety” below. |
max_rows | integer | yes (default None) | A cap on returned rows. May only lower the server's configured max_rows, never raise it (see below). A value <= 0 is treated as “not supplied” and the configured cap is used. |
Returns a dict:
{ "columns": ["id", "name"], "rows": [{"id": 1, "name": "alice"}], "query_id": "24680246-1000-abcd-8888-0123456789ab", "truncated": false }
If the result was truncated, a "note" key is added:
{ "truncated": true, "note": "Results were truncated at 100 rows. Narrow the query or aggregate to see the rest." }
query_id is Drill's own query UUID on the REST backend, useful as input to get_profile/cancel_query. On the JDBC backend query_id is always null — JdbcClient.query has no way to recover it from a plain JDBC cursor, so get_profile/cancel_query are of no use for a query run through JDBC even independent of their REST-only restriction.
Errors
sql must be a string — sql was not a str.max_rows must be an integer — max_rows was supplied but not an int.VALIDATION ERROR: no such table), passed through from DrillError unchanged, truncated at 2000 characters.Row cap. The effective cap is min(max_rows, config.max_rows) when max_rows is a positive integer, otherwise config.max_rows. A caller cannot ask for more rows than the operator configured; it can only ask for fewer.
Every statement is parsed with sqlglot's Drill dialect — a real parser, not a regex — before it reaches Drill. The policy is deny-by-default:
SELECT, set operations, subqueries), as long as no write is embedded inside them (e.g. a WITH x AS (INSERT ...) SELECT * FROM x, or a nested CREATE/DROP/INSERT/UPDATE/DELETE/ MERGE anywhere in the parse tree — checked structurally, not textually).CREATE TABLE AS, CREATE VIEW, DROP TABLE, DROP VIEW are permitted only when their target is schema-qualified and that schema matches an entry in writable_plugins (see docs/configuration.md). An unqualified target (no schema. prefix) is always rejected, since there is nothing to check it against. CREATE/DROP of anything other than TABLE/VIEW (e.g. a schema or function) is rejected outright.INSERT, UPDATE, DELETE, MERGE, ALTER, USE, REFRESH are always rejected — writable_plugins has no effect on them. There is no configuration path that permits them.ALTER SYSTEM and storage-plugin creation/update/deletion are not implemented anywhere in this codebase. There is no flag, config key, or hidden code path that turns them on — nothing exists for such a tool call to reach.SHOW and DESCRIBE are always permitted (metadata-only).EXPLAIN / EXPLAIN PLAN FOR recurses. The guard strips the leading keyword and re-checks the remaining statement text against the same rules (so EXPLAIN cannot be used to peek at or run a statement that would otherwise be rejected). Recursion is bounded (5 levels), so a pathological EXPLAIN EXPLAIN EXPLAIN ... chain cannot exhaust the stack — it is rejected instead.sql must parse to exactly one statement; stacked statements (SELECT 1; SELECT 2) are rejected.SHOWIf hidden_schemas is configured, run_query additionally:
SELECT * FROM sys.options with hidden_schemas: [sys]). This applies to ordinary SELECT/CREATE/DROP statements, where sqlglot can identify the table references structurally.SHOW/DESCRIBE-style command for a hidden schema‘s name as a whole word (sqlglot has no dedicated grammar for Drill’s SHOW, so the whole command falls back to an opaque Command node with everything after the keyword left as unparsed text; this regex scan is a best-effort, word-boundary text match over that remainder, and is documented in code as a target for replacement should Drill's grammar ever expose SHOW's target as a real expression).SHOW command — not just SHOW SCHEMAS/SHOW DATABASES — dropping any row whose first column matches a hidden-schema prefix. This is deliberate and fail-closed: three narrower attempts to detect specifically-schema-listing SHOW variants (a regex over the raw SQL, exact match against sqlglot‘s parsed literal, a comment-stripping regex over that literal) were each defeated by some spelling (an embedded comment, LIKE '...' clauses, unbalanced comment delimiters) and leaked a hidden schema name through. The trade-off: a SHOW TABLES/SHOW FILES row for a real table or file that happens to be named after a hidden-schema prefix (e.g. a table literally called sys in a workspace, under hidden_schemas: [sys]) is also dropped, even though it isn’t a schema at all. A false positive here — a real table briefly missing from a listing — is accepted in exchange for never leaking a schema name the operator asked to hide.Hiding INFORMATION_SCHEMA does not break list_schemas, list_tables, or describe_table. All three query INFORMATION_SCHEMA internally regardless of what's hidden; they simply omit hidden entries from what they return to the caller.
list_schemaslist_schemas() -> list[dict]
Lists every schema visible on the cluster, minus any matching hidden_schemas.
Parameters: none.
Returns a list of dicts, one per schema:
[ {"name": "dfs.tmp", "type": "file"}, {"name": "sys", "type": "system-tables"} ]
type is Drill's INFORMATION_SCHEMA.SCHEMATA.TYPE column, unmodified.
Errors: Drill's own error text on a connection or query failure.
list_tableslist_tables(schema: str) -> list[dict]
Lists the tables (and, for file plugins, files/directories) in one schema.
Parameters
| Name | Type | Optional | Notes |
|---|---|---|---|
schema | string | no | A dotted schema/workspace path, e.g. dfs.tmp. |
Returns a list of dicts:
[ {"name": "orders", "type": "TABLE"}, {"name": "sales_view", "type": "VIEW"}, {"name": "logs", "type": "DIRECTORY"} ]
Behavior an operator should expect: for a file-based plugin (storage plugin TYPE = file, e.g. dfs, s3), list_tables uses SHOW FILES FROM <schema> instead of INFORMATION_SCHEMA.TABLES — file plugins do not register their contents in INFORMATION_SCHEMA at all, so querying TABLES for one would silently return an empty list that looks like an empty workspace. Under SHOW FILES:
<name>.view.drill is reported as {"name": "<name>", "type": "VIEW"} — the .view.drill suffix (how Drill persists a view on a filesystem workspace) is stripped.isDirectory: true is reported as "type": "DIRECTORY", not "TABLE".For any other plugin type, INFORMATION_SCHEMA.TABLES is queried directly and type is whatever TABLE_TYPE reports (typically TABLE or VIEW).
Errors
schema '<x>' is hidden by configuration — schema matches a hidden_schemas entry. Refused before any query is issued.describe_tabledescribe_table(schema: str, table: str) -> list[dict]
Lists one table's columns, with type and (where determinable) nullability.
Parameters
| Name | Type | Optional | Notes |
|---|---|---|---|
schema | string | no | Dotted schema/workspace path. |
table | string | no | Table/view/file name. May itself contain a literal . (e.g. sales.csv on a file plugin) — treated as one identifier, not a nested path. |
Returns a list of dicts:
[ {"name": "id", "data_type": "INTEGER", "nullable": true}, {"name": "created_at", "data_type": "TIMESTAMP", "nullable": null} ]
Behavior an operator should expect — this is the most surprising tool in the package. The strategy depends on the storage plugin's TYPE, read from INFORMATION_SCHEMA.SCHEMATA (an extra round trip on every call):
file, mongo, or splunk): uses DESCRIBE <schema>.<table>. Metadata-only, never reads user data. Reports real nullability (IS_NULLABLE from Drill).file, mongo, splunk): DESCRIBE cannot answer for these — Drill discovers their schema at read time, not from a registry. describe_table instead probes with SELECT * FROM <schema>.<table> LIMIT 1 (or, for mongo specifically, SELECT \**` FROM . LIMIT 1, since Mongo's dynamic-field syntax differs from a plain *). Only the probe's **column names and types** are used; the sampled row's actual values are never read out of the response and never appear in the tool's output, under any failure mode. **Nullability cannot be determined from one sampled row** — a NULLin the one row says nothing about whether the column *can* be non-null, and vice versa — sonullableis reported asnull` (not guessed) on this path.describe_table raises rather than silently returning [] — an empty column list would be indistinguishable from “this table has no columns,” which is a different and misleading claim.TYPE = http): Drill has no schema for an HTTP source until a real request has been made against it — there is nothing to DESCRIBE and nothing to probe. describe_table raises a ToolError that says so explicitly and suggests running SELECT * FROM <schema>.<table> LIMIT 10 and reading the column names from the result, rather than returning an empty list that would look like “no columns.”Errors
schema '<x>' is hidden by configuration — refused before any query.invalid identifier: '<x>' — table contains characters outside the safe set (letters, digits, _, $, ., -), or an unsafe .-segment (empty, or .., which could otherwise reach a parent directory on a file plugin).columns could not be determined for . because the probe returned no rows; the table may be empty. — dynamic-schema probe returned zero rows.list_storage_pluginslist_storage_plugins() -> list[dict]
Lists every configured storage plugin, with secrets redacted.
Parameters: none. Requires the REST backend.
Returns a list of plugin config dicts, shape defined by Drill's own /storage.json, e.g.:
[ { "name": "s3", "config": { "type": "file", "connection": "s3a://my-bucket", "accessKey": "***REDACTED***", "secretKey": "***REDACTED***" } } ]
Redaction is always on and is not configurable off — see drill_mcp/redact.py. Any dict key matching (case insensitively, anywhere in the key) password, passwd, secret, credential, token, access[._-]?key, private[._-]?key, api[._-]?key, session[._-]?key, authorization, passphrase, keytab, or principal has its value replaced with "***REDACTED***", recursively through nested dicts, lists, and tuples. This is deliberately broad — a false-positive redaction is a cosmetic problem, a missed one is a leaked credential — so a non-secret key that happens to match (e.g. a plugin literally named tokenizer) is redacted too.
Plugins backing a hidden schema (per hidden_schemas) are omitted from the returned list entirely, matched on the plugin's name.
Errors
'storage_plugins' needs a REST connection to Drill; the JDBC backend does not expose management endpoints — JDBC backend.cluster_statuscluster_status() -> dict
Reports Drillbit membership and overall cluster status.
Parameters: none. Requires the REST backend.
Returns the merge of Drill's /cluster.json and /status.json responses into one dict (/status.json's keys win on any name collision):
{ "drillbits": [{"address": "10.0.0.5", "userPort": 31010, "state": "ONLINE"}], "status": "Running!", "version": "1.21.1" }
Errors
'cluster_status' needs a REST connection to Drill; the JDBC backend does not expose management endpoints — JDBC backend.list_profileslist_profiles(limit: int = 20) -> list[dict]
Lists recent and currently-running query profiles, newest first.
Parameters
| Name | Type | Optional | Notes |
|---|---|---|---|
limit | integer | yes, default 20 | Caps the number of profiles returned. Combines Drill's runningQueries and finishedQueries lists (running first) and truncates to limit. A negative limit is clamped to 0 (returns []), not rejected. |
Note: the default is 20, not None — check the live tool schema if a client surfaces defaults, since a caller passing no limit at all still gets at most 20 profiles back, not an unbounded list.
Returns a list of profile summary dicts, shape defined by Drill‘s /profiles.json, passed through the same secret redaction as list_storage_plugins (profiles are cluster-wide and can carry other users’ connection strings). Any entry whose query text names a hidden_schemas entry is dropped entirely, the same protection list_schemas/list_tables apply — otherwise a hidden schema‘s name would leak out as data in another user’s query text:
[ {"queryId": "24680246-...", "state": "RUNNING", "query": "SELECT * FROM ..."}, {"queryId": "13570246-...", "state": "COMPLETED", "query": "SELECT 1"} ]
Errors
'profiles' needs a REST connection to Drill; the JDBC backend does not expose management endpoints — JDBC backend.limit must be an integer — limit was supplied but not an int (a bool is explicitly rejected too, since bool is a subclass of int in Python and True/False as a row limit would be a confusing accident).get_profileget_profile(query_id: str) -> dict
Fetches the full profile for one query id.
Parameters
| Name | Type | Optional | Notes |
|---|---|---|---|
query_id | string | no | Drill's query UUID, as returned in run_query's query_id field (REST backend only — always null on JDBC). Validated against [A-Za-z0-9-]+. |
Returns Drill‘s full profile JSON for that query — the complete /profiles/<query_id>.json payload (fragments, operator metrics, timing, etc.), which can be large — passed through the same secret redaction as list_storage_plugins. A full profile embeds Drill’s serialized physical plan, which for JDBC and HTTP storage plugins can carry plugin configuration, so this is not returned unmodified.
Errors
'profile' needs a REST connection to Drill; the JDBC backend does not expose management endpoints — JDBC backend.query_id must be a string — wrong type.invalid query id: '<x>' — query_id contains characters outside [A-Za-z0-9-]. Rejected before any request is made.profile '<id>' references a hidden schema — the profile's query text names a hidden_schemas entry.cancel_querycancel_query(query_id: str) -> str
Cancels a running query by its query id.
Parameters
| Name | Type | Optional | Notes |
|---|---|---|---|
query_id | string | no | Same validation as get_profile. |
Returns Drill's raw response text from /profiles/cancel/<query_id> (a short human-readable string, e.g. "Cancelled query <id>"), passed through unmodified — not parsed as JSON, not restructured.
Errors
'cancel_query' needs a REST connection to Drill; the JDBC backend does not expose management endpoints — JDBC backend.query_id must be a string — wrong type.invalid query id: '<x>' — invalid characters.