MCP server — letting an LLM query skaidb

skaidb-mcp is a Model Context Protocol server that exposes skaidb to an LLM as a small set of tools. It runs locally over stdio, spawned by the MCP client — there is no listener and no port.

// Claude Desktop / Claude Code MCP config
{
  "mcpServers": {
    "skaidb": {
      "command": "skaidb-mcp",
      "args": ["--endpoint", "127.0.0.1:7000", "--user", "mcp_reader",
               "--database", "app"],
      "env": { "SKAIDB_MCP_PASSWORD": "…" }
    }
  }
}

Installing

skaidb-mcp is its own package, separate from the database server:

sudo apt install skaidb-mcp          # or the .rpm

It has no dependency on the skaidb package, because it is meant to sit where your LLM client sits — a laptop, a workstation, a dev container — and talk to a cluster over the network. Installing a database server does not install it, and installing it does not drag in a server.

For macOS and Windows, where the LLM client usually runs, take the standalone archive: skaidb-mcp-X.Y.Z-<target>.tar.gz, or the raw skaidb-mcp-X.Y.Z-x86_64-pc-windows-msvc.exe.

Two gates, one boundary

The capability tier decides what the server will attempt. The GRANT decides what the database will permit. Only the second is a security boundary.

--capabilities runs inside the process the model is talking to. It bounds blast radius and makes an agent's mistakes cheap — it is not what stands between a hostile input and your data. A role that was never granted DELETE cannot delete, whatever this process believes about itself.

So provision the role for the highest tier you intend to use, and let the flag select a subset per run. The tier only ever narrows. Starting with --capabilities admin against a SELECT-only role produces permission errors from the database, which is correct — not silent escalation.

Note server.read_only is a node-level setting and cannot make one connection read-only. Read-only means GRANT SELECT and nothing else.

Setting it up

On the database, a dedicated least-privilege role:

CREATE USER mcp_reader PASSWORD '<generated>';
GRANT SELECT ON orders TO mcp_reader;      -- name tables deliberately

Grant per table rather than per database where you can. Grants are table-level, not column-level, so a table is all-or-nothing: if it mixes sensitive and safe columns, everything in it is exposed to the model. See "What it cannot do" below.

The password comes from SKAIDB_MCP_PASSWORD or from password_file in the config — never a command-line flag, because ps shows those to every user on the machine and shells record them. A password_file must not be group- or world-readable.

A config file can replace the flags:

endpoints     = ["127.0.0.1:7000"]
user          = "mcp_reader"
password_file = "/etc/skaidb/mcp.pw"   # chmod 600
database      = "app"
capabilities  = "read"                  # read | write | ddl | schedule | admin
max_rows      = 100
timeout_secs  = 30
scan_budget_rows = 100000   # optional: server-side per-statement scan cap
# tls = true                # with tls_ca: verify against it; without: public-CA roots
# tls_insecure = true       # explicit opt-out of verification (dev only)

scan_budget_rows is the one REAL resource control here: it issues SET SCAN BUDGET ROWS <n> on the session, and the server enforces min(node config, n) per statement — tightening-only, so a prompt-injected agent cannot lift it. (max_rows merely truncates what the model sees; timeout_secs bounds the agent's wait, not the node's work.) The driver stores it and replays it after every reconnect, so a silently re-established session stays capped.

Capability tiers

Cumulative, lowest first. Default read.

Tier Adds Tools
read SELECT, SHOW, DESCRIBE, EXPLAIN list_tables, describe_table, query, explain
write INSERT, UPDATE, DELETE, CALL write
ddl CREATE/ALTER/DROP of tables, indexes, streams ddl
schedule procedures, jobs, triggers schedule
admin GRANT, users, roles, cluster, SET CONFIG admin

Tools above the running tier are not advertised rather than advertised and refused — a tool that always fails costs the model turns to discover, and a refusal reads like something to work around.

Two tiers deserve their reputation:

  • schedule creates things that outlive the session. A job runs on the server, on its own schedule, as the role that created it — so an injected CREATE JOB is a foothold that keeps its privileges after you close the chat and revoke the role, because the definer is stamped at creation.
  • admin includes GRANT, and a role that can grant can grant itself anything — which makes every lower tier advisory. The server warns loudly at startup when either is selected.

What the server does to your SQL

  • Classifies with the real parser. Statements are parsed with the same parser the database authorizes against, and matched on structure. A regex gate would refuse WHERE note = 'DROP TABLE users' (a legitimate read) and miss EXPLAIN DELETE …; this refuses neither and catches the latter, because EXPLAIN inherits the tier of what it explains.
  • Refuses what will not parse, rather than guessing.
  • Requires a WHERE on UPDATE/DELETE. Write WHERE 1 = 1 if you genuinely mean every row.
  • Adds a LIMIT to a SELECT that has none, and says when results are truncated — a model shown 100 of 4,000 rows and not told will reason about the 100 as though they were all of them.
  • Checks the tool matches the statement. A DROP submitted through the write tool is refused even on a server whose tier permits DDL, so the tool name a human approves means what it says.

The risk that is specific to an LLM

Rows are inputs to the model. A row whose text says "ignore previous instructions and…" is, at the model layer, indistinguishable from your own request. Any table an untrusted party can write to — an inbox, a comment, a webhook payload, a scraped page, a log line — is therefore an injection vector into whatever the agent does next.

No system prompt fixes this. The only real control is that the injected instruction must find a tool that can act, which is why the default is read-only and why writes sit behind a tier you turn on deliberately.

The second consequence is exfiltration: whatever the model reads leaves the building, into the model provider and into conversation history that may be shared. Grant accordingly.

What it cannot do

  • Column-level restriction. Grants are table-level. A procedure cannot project around it either — CALL runs with invoker rights, so the caller still needs SELECT on the underlying table. What works is a definer-run job maintaining a projection table of safe columns, with the MCP role granted SELECT on that table alone.
  • Bound the node's work. --timeout-secs is a client-side ceiling: it bounds how long the agent waits, not how long the statement runs. Scan ceilings are node-level (storage.scan_row_budget, scan_byte_budget, statement_timeout_secs) and apply to every client equally.
  • Survive a reconnect with session state. The driver re-dials, re-authenticates and re-enters the session database, but a statement that failed on a dead socket is not retried, because it may already have run. A write reported as failed may have applied — re-read before retrying. Primary-key INSERTs upsert and are safe to repeat; SET n = n + 1 is not.

Startup checks

The server refuses to run as a role holding global ADMIN unless --allow-superuser is passed, and logs the role and its grants to stderr so a misconfiguration is visible immediately rather than the first time something destructive succeeds.

That check reads the privilege model, which itself requires privilege — so a correctly least-privileged role cannot run it. That inverts usefully: a role refused permission to read grants is definitionally not one that controls them, so the refusal is treated as a pass and logged as the expected case.

Diagnostics go to stderr, which MCP clients surface as server logs. Stdout carries protocol only.