@mcpolyglot/connector-sql
v0.4.0
Published
Shared SQL connector for mcpolyglot with Postgres, MySQL/MariaDB, and SQLite dialects. Read-only-by-default with per-dialect enforcement.
Readme
@mcpolyglot/connector-sql
SQL connector for mcpolyglot. One package, three dialects: Postgres, MySQL/MariaDB, and SQLite. Read-only by default with per-dialect enforcement at the database boundary.
Tools exposed
For every SQL source mcpolyglot generates:
<id>.list_tables— every table across non-system schemas, with columns.<id>.describe_table— one table's columns, types, and primary key.<id>.query— read-only SQL with parameterized args, row cap, and timeout.
How read-only is enforced
Three layers, each enough on its own for the common case:
| Layer | Postgres | MySQL | SQLite |
| --------------------------------- | -------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------- |
| Policy classifier (every dialect) | parses the SQL, denies before the DB sees it | same | same |
| Per call (query) | BEGIN READ ONLY, rolled back | AST gate + START TRANSACTION READ ONLY + MAX_EXECUTION_TIME hint | statement must be a reader |
| Per connection (no write table) | default_transaction_read_only=on | SESSION TRANSACTION READ ONLY on every pooled connection | file opened readonly + query_only |
The MySQL AST gate matters because MySQL's transaction read-only mode doesn't cover everything. The connection layer is a session default, so a SET could undo it; the classifier blocks SET. A database role without write grants is still the real backstop.
Policy layer
Every query call is parsed (node-sql-parser, per-dialect grammar) and checked against the source's policy before it reaches the database. Denials come back as forbidden.policy with a specific reason.
policy: {
tables: { users: 'read', orders: 'write', 'public.secrets': 'none' },
defaultAccess: 'read', // 'read' | 'none' — never 'write'
denyColumns: ['users.password_hash', '*.ssn'],
maxRows: 100, // min'd with limits.rowCap
statementTimeoutMs: 5000, // min'd with limits.timeoutMs
maxWritesPerCall: 1, // execute rolls back above this many changed rows
idempotencyWindowMinutes: 10, // execute idempotencyKey memory
}Rules (each has a case in src/__tests__/policy.test.ts):
- Unparseable SQL is denied. So is more than one statement per call.
- Anything other than
SELECT/INSERT/UPDATE/DELETEis always denied: DDL,GRANT,TRUNCATE,SET, and so on. UPDATE/DELETEwithoutWHEREis denied, as is aWHEREthat names no column (WHERE 1=1). This catches a forgotten clause; a determined full-table write is stopped bymaxWritesPerCallrolling it back, not by this check.SELECT ... INTO OUTFILE/DUMPFILE(MySQL) andSELECT ... INTO table(Postgres) are denied.- Functions that reach the server's filesystem, other hosts, or the session itself are denied by name in every dialect:
pg_read_file,pg_ls_dir,lo_import/lo_export,dblink*,pg_sleep,LOAD_FILE,SLEEP,load_extension, and so on (DENIED_FUNCTIONSinpolicy.ts). The list is a backstop; revoke the privileges at the database. - System catalogs (
information_schema,pg_catalog,pg_*,mysql,performance_schema,sys,sqlite_*) are denied under anydefaultAccessunless atableskey names the table. - Every table referenced anywhere (joins, subqueries,
INSERT … SELECT) is checked.nonetables are denied and hidden fromlist_tables. Writes need an explicitwriteentry. - When several policy keys could match a table (
usersandpublic.users), the most restrictive wins. - A denied column is blocked wherever it appears: select list,
WHERE, subqueries, aliased tables.SELECT *is denied if it could expose one. With a*.colrule, that means everySELECT *. Denied columns are also stripped fromlist_tables/describe_table. - A whole-row reference is treated like
SELECT *on that table:SELECT u FROM users u,to_jsonb(u),json_agg(u),row_to_json(users),u::text. Any bare identifier that matches a table name or alias in the query counts, so a column that shares its table's name is denied too when that table has denied columns. - Second layer, after the query runs: any object or array value in the result has keys named like a denied column removed, at any depth. This is what stops a denied column that still arrives inside a JSON value the classifier could not attribute.
dryRun: truereturns{ decision }without executing.
Known limits:
- The function denylist is by name. A user-defined wrapper, an extension function not on the list, or
COPY ... TO PROGRAM(blocked as non-SELECT) are examples of why the classifier is defense-in-depth. The database user's privileges are the control:mcpolyglot doctorfails when that user is a superuser, is inpg_read_server_files/pg_write_server_files/pg_execute_server_program, or holds MySQLFILE/SUPER/ALL PRIVILEGES. - A whole-row value cast to text (
u::text) is not JSON, so the output filter cannot strip fields from it; the classifier denies the reference instead. If a parser gap lets one through, the redactor still masks the built-in PII patterns. querynever writes, even on a writable connection: Postgres/MySQL run it in a read-only transaction and SQLite refuses non-reader statements.
Writes and connection safety
The connection's mode follows the policy:
- No
writetable (the default): the database itself refuses writes. Postgres pools setdefault_transaction_read_only=on, MySQL setsSESSION TRANSACTION READ ONLYon every new pool connection (a failed SET drops the connection), SQLite opens the filereadonlywithquery_only. Theexecutetool is not registered. - At least one
writetable: the connection is writable and<id>.executeappears (scopetables:write).
execute runs one INSERT/UPDATE/DELETE that classify() allows, inside a transaction, with statementTimeoutMs. If it changes more than maxWritesPerCall rows it rolls back and fails with forbidden.policy (the reason carries both counts). Success returns { rowsAffected }, also in metadata.rows. dryRun works as in query.
idempotencyKey (optional): a retry with the same key within idempotencyWindowMinutes returns the first result without writing again. The same key with different sql/params fails with invalid_argument. Failed calls are not remembered. Keys live in process memory, per connector: a restart or a second replica forgets them.
Source-level limits:
{ id: 'pg.main', kind: 'postgres', url: '…',
pool: { max: 4, idleTimeoutMs: 30000 }, // pg / mysql2 pool; SQLite ignores it
maxConcurrentQueries: 8 } // query + execute in flight on this sourceCalls over maxConcurrentQueries are rejected immediately with rate_limited, not queued, so the agent sees back-pressure instead of a hidden wait.
Known gaps: MySQL has no server-side timeout for DML, so execute uses the driver's client-side timeout (the server may run on briefly). SQLite checks the timeout after the statement finishes, then rolls back. A replayed idempotent call returns the old result even if the row has since changed.
Drivers are optional deps
The dialect drivers (pg, mysql2, better-sqlite3) are declared as optionalDependencies. Install only what you use.
Docs
- Architecture → https://github.com/ishay60/mcpolyglot/blob/develop/ARCHITECTURE.md
- Examples →
MIT licensed.
