blob: 1c8ff5ea50ff310ec7768c0e89055b49744646a7 [file] [view]
# Tool reference
All nine tools registered by `drill-mcp` (see
[`drill_mcp/server.py`](../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`](../drill_mcp/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_query`
```
run_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:
```json
{
"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:
```json
{
"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`.
- Any policy rejection (see "Safety" below) — message explains which rule
was violated (unwritable plugin, hidden schema, multiple statements,
disallowed statement type, unparseable SQL).
- Drill's own error text (e.g. `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.
### Safety (the SQL policy)
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:
- **Reads are always permitted** (`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`](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.
- **Exactly one statement per call.** `sql` must parse to exactly one
statement; stacked statements (`SELECT 1; SELECT 2`) are rejected.
- **Anything that fails to parse is rejected**, including a tokenizer
failure (pathologically deep nesting, unterminated quoting, etc.) — a
parse failure is never treated as "unknown, so allow it."
### Hidden schemas and `SHOW`
If `hidden_schemas` is configured, `run_query` additionally:
1. **Rejects, before the query ever reaches Drill**, any statement whose
parsed table references resolve into a hidden schema (e.g.
`SELECT * FROM sys.options` with `hidden_schemas: [sys]`). This applies
to ordinary `SELECT`/`CREATE`/`DROP` statements, where sqlglot can
identify the table references structurally.
2. **Also scans the raw text of any `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).
3. **Filters the result rows of *every* `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_schemas`
```
list_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:
```json
[
{"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_tables`
```
list_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:
```json
[
{"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`:
- A file named `<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.
- Anything Drill's directory listing marks `isDirectory: true` is reported
as `"type": "DIRECTORY"`, not `"TABLE"`.
- Results are sorted by name.
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.
- Drill's own error text otherwise.
---
## `describe_table`
```
describe_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:
```json
[
{"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):
- **Static-schema plugins** (anything not `file`, `mongo`, or `splunk`):
uses `DESCRIBE <schema>.<table>`. Metadata-only, never reads user data.
Reports real nullability (`IS_NULLABLE` from Drill).
- **Dynamic-schema plugins** (`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 <schema>.<table> 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 `NULL` in the one row says
nothing about whether the column *can* be non-null, and vice versa — so
`nullable` is reported as `null` (not guessed) on this path.
- If the probe returns **zero rows** (e.g. the table is genuinely empty),
`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.
- **HTTP plugins** (`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).
- The explicit HTTP-plugin message above.
- `columns could not be determined for `<schema>`.`<table>` because the
probe returned no rows; the table may be empty.` — dynamic-schema probe
returned zero rows.
- Drill's own error text for any other query failure (missing table,
permissions, etc.), unmodified — Drill's error text does not embed cell
content, so nothing is redacted from it here.
---
## `list_storage_plugins`
```
list_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.:
```json
[
{
"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`](../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.
- Drill's own error text otherwise.
- A non-dict entry in Drill's plugin list is silently skipped rather than
raising (defensive, in case Drill's REST response is malformed).
---
## `cluster_status`
```
cluster_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):
```json
{
"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.
- Drill's own error text otherwise.
---
## `list_profiles`
```
list_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:
```json
[
{"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).
- Drill's own error text otherwise.
---
## `get_profile`
```
get_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.
- Drill's own error text otherwise (e.g. no profile with that id).
---
## `cancel_query`
```
cancel_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.
- Drill's own error text otherwise (e.g. the query id does not exist or has
already completed).