npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/DELETE is always denied: DDL, GRANT, TRUNCATE, SET, and so on.
  • UPDATE/DELETE without WHERE is denied, as is a WHERE that names no column (WHERE 1=1). This catches a forgotten clause; a determined full-table write is stopped by maxWritesPerCall rolling it back, not by this check.
  • SELECT ... INTO OUTFILE/DUMPFILE (MySQL) and SELECT ... 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_FUNCTIONS in policy.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 any defaultAccess unless a tables key names the table.
  • Every table referenced anywhere (joins, subqueries, INSERT … SELECT) is checked. none tables are denied and hidden from list_tables. Writes need an explicit write entry.
  • When several policy keys could match a table (users and public.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 *.col rule, that means every SELECT *. Denied columns are also stripped from list_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: true returns { 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 doctor fails when that user is a superuser, is in pg_read_server_files/pg_write_server_files/pg_execute_server_program, or holds MySQL FILE/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.
  • query never 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 write table (the default): the database itself refuses writes. Postgres pools set default_transaction_read_only=on, MySQL sets SESSION TRANSACTION READ ONLY on every new pool connection (a failed SET drops the connection), SQLite opens the file readonly with query_only. The execute tool is not registered.
  • At least one write table: the connection is writable and <id>.execute appears (scope tables: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 source

Calls 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

MIT licensed.