Agents write SQL for databases they cannot reach, so they guess: whether PostgreSQL accepts a
GROUP BY, what a column is called, whether DATE_SUB exists, what round(2.5) returns. SQL Check
stops the guessing. It runs your SQL on the real engines, in memory: PostgreSQL 18 (PGlite, the
PostgreSQL server compiled to WebAssembly) and SQLite 3.49 (sql.js). No database server, no network.
- Your schema first: CREATE TABLE statements, migrations, sample rows. Then each statement gets the engine's own verdict: the error with its line and column and PostgreSQL's hint ("Perhaps you meant to reference the column users.email"), the constraint it violates with the failing row, or the rows it returns.
- Every statement is checked, not just the first: each runs in its own savepoint inside one transaction that is rolled back, so an error does not hide the next one and nothing persists.
- SQL from other databases: when MySQL or SQL Server syntax fails (backticks,
DATE_SUB,LIMIT 0, 10,AUTO_INCREMENT,TOP n), the error says what the engine accepts instead. - Portability:
dialect: bothruns the SQL on PostgreSQL and SQLite and lists where they disagree, including the quiet differences: SQLite fills anINTEGER PRIMARY KEYby itself and does not enforce foreign keys unless asked; PostgreSQL does neither and does.
No key needed.
Built and maintained by Arhan Canli.
Needs Node.js 20 or newer. No account or key.
Claude Code
claude mcp add sql-check -- npx -y sql-check-mcpClaude Desktop: download sql-check-mcp-<version>.mcpb from the latest release and open it. The bundle is signed; verify it with gh attestation verify <file> --repo arhancanli/sql-check-mcp.
Any other client (Windsurf, Zed, Cline, Continue and others), in its MCP config file:
{
"mcpServers": {
"sql-check": {
"command": "npx",
"args": [
"-y",
"sql-check-mcp"
]
}
}
}Docker
docker build -t sql-check-mcp https://github.com/arhancanli/sql-check-mcp.git && docker run -i --rm sql-check-mcpHosted (Streamable HTTP): node src/server.mjs --http serves stateless MCP at POST /mcp (port from PORT, default 3000).
An agent calls check_sql with:
{
"schema": "CREATE TABLE users (\n id serial PRIMARY KEY,\n email text NOT NULL UNIQUE,\n created_at timestamptz DEFAULT now()\n);\nCREATE TABLE orders (\n id serial PRIMARY KEY,\n user_id int NOT NULL REFERENCES users(id),\n total numeric(10,2) NOT NULL,\n status text NOT NULL CHECK (status IN ('open', 'paid'))\n);\nINSERT INTO users (email) VALUES ('[email protected]'), ('[email protected]');\nINSERT INTO orders (user_id, total, status) VALUES (1, 10.50, 'open'), (1, 3.00, 'paid');\n",
"sql": "-- spend per user\nSELECT u.email, sum(o.total) AS spent\nFROM users u LEFT JOIN orders o ON o.user_id = u.id\nGROUP BY u.email\nORDER BY u.email;\nSELECT u.email, o.total FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.email;\nSELECT emial FROM users;\nSELECT * FROM orders WHERE placed_at > DATE_SUB(NOW(), INTERVAL 7 DAY);\nINSERT INTO orders (user_id, total, status) VALUES (1, 5.00, 'shipped');\nINSERT INTO orders (user_id, total, status) VALUES (9, 5.00, 'open');\nUPDATE orders SET status = 'paid' WHERE status = 'open' RETURNING id, status;\n"
}and gets back (recorded from the live server on 2026-09-27):
{
"results": [
{
"dialect": "postgres",
"version": "PostgreSQL 18.3",
"counts": {
"ok": 2,
"error": 5
},
"statements": [
{
"line": 2,
"sql": "SELECT u.email, sum(o.total) AS spent FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP B...",
"status": "ok",
"command": "SELECT",
"columns": [
"email text",
"spent numeric"
],
"row_count": 2,
"rows": [
[
"[email protected]",
"13.50"
],
[
"[email protected]",
null
]
]
},
{
"line": 6,
"sql": "SELECT u.email, o.total FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.email",
"status": "error",
"column": 17,
"error": "column \"o.total\" must appear in the GROUP BY clause or be used in an aggregate function",
"code": "42803"
},
{
"line": 7,
"sql": "SELECT emial FROM users",
"status": "error",
"column": 8,
"error": "column \"emial\" does not exist",
"hint": "Perhaps you meant to reference the column \"users.email\".",
"code": "42703"
},
{
"line": 8,
"sql": "SELECT * FROM orders WHERE placed_at > DATE_SUB(NOW(), INTERVAL 7 DAY)",
"status": "error",
"column": 65,
"error": "syntax error at or near \"7\"",
"code": "42601",
"advice": "DATE_SUB/DATE_ADD are MySQL; PostgreSQL writes now() - interval '1 day'"
},
{
"line": 9,
"sql": "INSERT INTO orders (user_id, total, status) VALUES (1, 5.00, 'shipped')",
... (35 more lines)| Tool | What it does |
|---|---|
check_sql |
Runs SQL on real PostgreSQL 18 or SQLite 3.49 in memory, after your schema (DDL, migrations, sample INSERTs). Each statement gets the engine's verdict: error with line, column and hint, constraint violations, or the rows it returns (up to max_rows). All inside a rolled-back transaction. dialect both compares the two. |
- Nothing leaves your machine: the engines run inside this process, in memory, with in-memory file
systems; there is no network access at all (
factory.allowHostsis empty). - Each call starts from an empty database. PostgreSQL runs every statement in its own savepoint
inside one transaction that is rolled back, then discards the session; SQLite gets a new
database per call. Statements that would end that transaction (
BEGIN,COMMIT,ROLLBACK) are skipped and say so. - The engines run in a worker thread with a time limit (10 s per call,
SQL_CHECK_TIMEOUT_MSto change it): a statement that never ends is stopped, reported with its number, and the engine is replaced. They start in the background when the server starts, so the first call does not wait for PostgreSQL to boot. - Results are compact JSON with a matching output schema: up to
max_rowsrows per statement (default 20, with the full count), long values cut at 200 characters, bigints as strings.
Not yet measured.
Measured 2026-09-27 from Dubai, home connection against the live upstream, Node 24.19.0 (bench/perf.json, scripts/perf.mjs in the factory).
| Call | First call | Repeat | Result size |
|---|---|---|---|
| check_sql: eight statements against a schema with sample rows, on PostgreSQL | 827 ms | 7.7 ms | 1,855 chars |
| check_sql: the same SQL on PostgreSQL and SQLite, compared | 825 ms | 4.1 ms | 1,918 chars |
| check_sql: a migration step that cannot run inside a transaction | 892 ms | 2.9 ms | 692 chars |
First call: a fresh server process, including the TLS connection and the upstream's own time. Repeat: the same call again, answered from the in-process cache, so it shows this server's own overhead.
Tool definitions the model reads on every turn (name, description, input schema): 800 characters. The full tool list, with the output schemas and annotations clients use to validate results, is 1,357 characters.
- Actions Check: Checks GitHub Actions workflows: outdated actions, old Node runtimes, retired runners, injection.
- Config Check: Validates config files against their official schemas: tsconfig, compose, workflows, 1,400+ more.
- Cron Check: Explains cron expressions, lists next run times in any time zone, converts between cron dialects.
- Dockerfile Check: Checks Dockerfiles: build-breaking mistakes, base image tags that exist, digests, platforms, EOL.
- Domain Health: Email and domain checks: SPF lookup limits, DKIM keys, DMARC, DNS records, registration expiry.
- End of Life: Is this version still supported? EOL dates, latest patch and upgrade target for 470+ products.
- Internet Standards: RFC sections, status, obsoleted-by chains, errata and IANA registries for coding agents.
- Kube Check: Checks Kubernetes manifests for your version: removed APIs, unknown fields, Pod Security, risks.
- The whole collection, 12 more
MIT, Copyright (c) 2026 Arhan Canli.