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:
schedulecreates things that outlive the session. A job runs on the server, on its own schedule, as the role that created it — so an injectedCREATE JOBis a foothold that keeps its privileges after you close the chat and revoke the role, because the definer is stamped at creation.adminincludesGRANT, 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 missEXPLAIN DELETE …; this refuses neither and catches the latter, becauseEXPLAINinherits the tier of what it explains. - Refuses what will not parse, rather than guessing.
- Requires a
WHEREonUPDATE/DELETE. WriteWHERE 1 = 1if you genuinely mean every row. - Adds a
LIMITto aSELECTthat 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
DROPsubmitted through thewritetool 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 —
CALLruns with invoker rights, so the caller still needsSELECTon the underlying table. What works is a definer-run job maintaining a projection table of safe columns, with the MCP role grantedSELECTon that table alone. - Bound the node's work.
--timeout-secsis 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 + 1is 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.