@shibbirweb/mcp-db-read-only 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.dockerhub.md +354 -0
  4. package/README.md +387 -0
  5. package/dist/ApplicationFactory.js +144 -0
  6. package/dist/config/EnvironmentConfigLoader.js +179 -0
  7. package/dist/config/PackageVersionLoader.js +43 -0
  8. package/dist/connections/ConnectionManager.js +101 -0
  9. package/dist/connections/ConnectionRegistry.js +109 -0
  10. package/dist/connections/ConnectionTargetFactory.js +104 -0
  11. package/dist/connections/ConnectionUrlParser.js +195 -0
  12. package/dist/domain/ConnectionProfile.js +39 -0
  13. package/dist/domain/ConnectionTarget.js +139 -0
  14. package/dist/domain/Engine.js +159 -0
  15. package/dist/drivers/BaseDriver.js +35 -0
  16. package/dist/drivers/DatabaseDriver.js +1 -0
  17. package/dist/drivers/DriverCache.js +107 -0
  18. package/dist/drivers/DriverProvider.js +71 -0
  19. package/dist/drivers/DriverRegistry.js +24 -0
  20. package/dist/drivers/GlobPattern.js +41 -0
  21. package/dist/drivers/LazyResource.js +56 -0
  22. package/dist/drivers/document/MongoDriver.js +187 -0
  23. package/dist/drivers/document/MongoSchemaSampler.js +74 -0
  24. package/dist/drivers/document/MongoStageAllowlist.js +87 -0
  25. package/dist/drivers/keyvalue/RedisCommandFlagsGuard.js +69 -0
  26. package/dist/drivers/keyvalue/RedisDriver.js +224 -0
  27. package/dist/drivers/search/ElasticsearchDriver.js +159 -0
  28. package/dist/drivers/sql/ClickHouseDriver.js +156 -0
  29. package/dist/drivers/sql/MsSqlDriver.js +147 -0
  30. package/dist/drivers/sql/MySqlDriver.js +144 -0
  31. package/dist/drivers/sql/MySqlSessionInitializer.js +100 -0
  32. package/dist/drivers/sql/PostgresDriver.js +176 -0
  33. package/dist/drivers/sql/SqlIdentifier.js +36 -0
  34. package/dist/drivers/sql/SqliteDriver.js +202 -0
  35. package/dist/drivers/sql/SqliteProtocol.js +7 -0
  36. package/dist/drivers/sql/SqliteWorker.js +71 -0
  37. package/dist/errors/ApplicationError.js +15 -0
  38. package/dist/errors/EngineMismatchError.js +15 -0
  39. package/dist/errors/InvalidConnectionUrlError.js +14 -0
  40. package/dist/errors/InvalidProfileDefinitionError.js +15 -0
  41. package/dist/errors/NoActiveConnectionError.js +13 -0
  42. package/dist/errors/NoDatabaseSelectedError.js +13 -0
  43. package/dist/errors/ObjectNotFoundError.js +14 -0
  44. package/dist/errors/UnknownProfileError.js +16 -0
  45. package/dist/errors/UnsupportedOperationError.js +14 -0
  46. package/dist/errors/index.js +9 -0
  47. package/dist/formatting/JsonSerializer.js +49 -0
  48. package/dist/formatting/RowFormatter.js +43 -0
  49. package/dist/formatting/ToolResponse.js +25 -0
  50. package/dist/index.js +15 -0
  51. package/dist/server/McpDbServer.js +69 -0
  52. package/dist/tools/BaseTool.js +42 -0
  53. package/dist/tools/DatabaseScopedTool.js +61 -0
  54. package/dist/tools/QueryTools.js +13 -0
  55. package/dist/tools/browse/DescribeTableTool.js +37 -0
  56. package/dist/tools/browse/GetForeignKeysTool.js +36 -0
  57. package/dist/tools/browse/GetTableIndexesTool.js +30 -0
  58. package/dist/tools/browse/GetTableSampleTool.js +50 -0
  59. package/dist/tools/browse/ListTablesTool.js +51 -0
  60. package/dist/tools/connection/ConnectTool.js +67 -0
  61. package/dist/tools/connection/CurrentConnectionTool.js +36 -0
  62. package/dist/tools/connection/ListConnectionsTool.js +38 -0
  63. package/dist/tools/connection/ListDatabasesTool.js +41 -0
  64. package/dist/tools/connection/UseConnectionTool.js +57 -0
  65. package/dist/tools/connection/UseDatabaseTool.js +45 -0
  66. package/dist/tools/document/AggregateTool.js +44 -0
  67. package/dist/tools/document/CountDocumentsTool.js +32 -0
  68. package/dist/tools/document/DistinctValuesTool.js +36 -0
  69. package/dist/tools/document/DocumentTool.js +38 -0
  70. package/dist/tools/document/FindDocumentsTool.js +56 -0
  71. package/dist/tools/keyvalue/RedisCommandTool.js +40 -0
  72. package/dist/tools/search/SearchTool.js +54 -0
  73. package/dist/tools/sql/RunQueryTool.js +46 -0
  74. package/dist/types/config.types.js +1 -0
  75. package/dist/types/connection.types.js +1 -0
  76. package/dist/types/driver.types.js +1 -0
  77. package/dist/types/index.js +1 -0
  78. package/dist/types/tool.types.js +1 -0
  79. package/dist/types/validation.types.js +1 -0
  80. package/dist/validation/document/MongoOperatorGuard.js +72 -0
  81. package/dist/validation/keyvalue/RedisCommandValidator.js +176 -0
  82. package/dist/validation/names/NamePolicy.js +122 -0
  83. package/dist/validation/names/NamePolicyRegistry.js +33 -0
  84. package/dist/validation/search/SearchBodyValidator.js +56 -0
  85. package/dist/validation/sql/ReadOnlyQueryValidator.js +82 -0
  86. package/dist/validation/sql/SqlDialect.js +196 -0
  87. package/dist/validation/sql/SqlSkeletonizer.js +197 -0
  88. package/dist/validation/sql/SqlValidatorRegistry.js +24 -0
  89. package/dist/validation/sql/rules/AmbiguousSyntaxRule.js +23 -0
  90. package/dist/validation/sql/rules/EmptyQueryRule.js +16 -0
  91. package/dist/validation/sql/rules/ForbiddenPatternRule.js +30 -0
  92. package/dist/validation/sql/rules/LeadingKeywordRule.js +29 -0
  93. package/dist/validation/sql/rules/SingleStatementRule.js +26 -0
  94. package/dist/validation/sql/rules/SmuggledWriteRule.js +50 -0
  95. package/dist/validation/sql/rules/index.js +6 -0
  96. package/package.json +76 -0
@@ -0,0 +1,144 @@
1
+ import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
2
+ import { BaseDriver } from "../BaseDriver.js";
3
+ import { GlobPattern } from "../GlobPattern.js";
4
+ import { LazyResource } from "../LazyResource.js";
5
+ import { MySqlSessionInitializer } from "./MySqlSessionInitializer.js";
6
+ import { SqlIdentifier } from "./SqlIdentifier.js";
7
+ /**
8
+ * MySQL and MariaDB, through mysql2.
9
+ *
10
+ * Read-only layer two: every pooled connection is opened with
11
+ * `SET SESSION TRANSACTION READ ONLY` (see MySqlSessionInitializer), and
12
+ * multipleStatements is off, so the protocol cannot carry a second statement.
13
+ */
14
+ export class MySqlDriver extends BaseDriver {
15
+ tuning;
16
+ logger;
17
+ family = "sql";
18
+ dialect = "mysql";
19
+ /**
20
+ * Hidden by default: noise in almost every session. `include_system` on
21
+ * list_databases exists because inspecting them is occasionally the task.
22
+ */
23
+ static SYSTEM_SCHEMAS = new Set([
24
+ "information_schema",
25
+ "performance_schema",
26
+ "mysql",
27
+ "sys",
28
+ ]);
29
+ static FOREIGN_KEYS = `SELECT COLUMN_NAME, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME, CONSTRAINT_NAME
30
+ FROM information_schema.KEY_COLUMN_USAGE
31
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL`;
32
+ static NO_SUCH_TABLE = 1146;
33
+ pool;
34
+ constructor(target, tuning, logger) {
35
+ super(target);
36
+ this.tuning = tuning;
37
+ this.logger = logger;
38
+ this.pool = new LazyResource(() => this.createPool(), (pool) => pool.end());
39
+ }
40
+ async verify() {
41
+ await this.run("SELECT 1");
42
+ }
43
+ close() {
44
+ return this.pool.close();
45
+ }
46
+ async listDatabases() {
47
+ const rows = (await this.run("SHOW DATABASES"));
48
+ return rows.map((row) => {
49
+ const name = Object.values(row)[0];
50
+ return { name, system: MySqlDriver.SYSTEM_SCHEMAS.has(name) };
51
+ });
52
+ }
53
+ async listObjects(pattern, limit) {
54
+ this.requireDatabase();
55
+ const rows = (await this.run("SHOW TABLES"));
56
+ // SHOW TABLES returns single-key objects whose key name varies with the
57
+ // database (Tables_in_<name>). A plain list is far easier to read.
58
+ return new GlobPattern(pattern).apply(rows.map((row) => Object.values(row)[0]), limit);
59
+ }
60
+ async describeObject(name) {
61
+ return this.onTable(name, () => this.run(`SHOW COLUMNS FROM ${this.quote(name)}`));
62
+ }
63
+ async listIndexes(name) {
64
+ return this.onTable(name, () => this.run(`SHOW INDEX FROM ${this.quote(name)}`));
65
+ }
66
+ /** The foreign-key query answers a missing table with no rows, which reads as "no keys", so existence is checked first. */
67
+ async listForeignKeys(name) {
68
+ const qualified = SqlIdentifier.parse(name);
69
+ await this.onTable(name, () => this.run(`SELECT 1 FROM ${this.quote(name)} LIMIT 0`));
70
+ return this.run(MySqlDriver.FOREIGN_KEYS, [qualified.schema, qualified.name]);
71
+ }
72
+ async sample(name, limit) {
73
+ return this.onTable(name, () => this.run(`SELECT * FROM ${this.quote(name)} LIMIT ${limit}`));
74
+ }
75
+ query(sql) {
76
+ return this.run(sql);
77
+ }
78
+ /** Placeholders for values wherever the statement allows; identifiers are quoted. */
79
+ async run(sql, params) {
80
+ const pool = await this.pool.get();
81
+ const [rows] = await pool.query(sql, params);
82
+ return rows;
83
+ }
84
+ /**
85
+ * Runs work against one table, turning MySQL's "table doesn't exist" (error
86
+ * 1146) into the same ObjectNotFoundError every other engine raises, so a
87
+ * missing table reads the same whichever engine is behind the connection.
88
+ */
89
+ async onTable(name, work) {
90
+ try {
91
+ return await work();
92
+ }
93
+ catch (error) {
94
+ if (error.errno === MySqlDriver.NO_SUCH_TABLE) {
95
+ throw new ObjectNotFoundError(this.objectNoun, name);
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+ quote(name) {
101
+ return SqlIdentifier.quoteQualified(SqlIdentifier.parse(name), SqlIdentifier.backtick);
102
+ }
103
+ /**
104
+ * Imported on first use rather than at startup. Eight drivers loaded eagerly
105
+ * would put every one of them on the startup path of a server that will,
106
+ * in most sessions, only ever talk to one engine.
107
+ */
108
+ async createPool() {
109
+ const { default: mysql } = await import("mysql2/promise");
110
+ const pool = mysql.createPool({
111
+ host: this.target.host,
112
+ port: this.target.port,
113
+ user: this.target.user,
114
+ password: this.target.password,
115
+ database: this.target.database || undefined,
116
+ connectionLimit: this.tuning.connectionLimit,
117
+ waitForConnections: true,
118
+ // The single most important option here. With multiple statements
119
+ // enabled, the read-only guarantee would rest entirely on the validator
120
+ // finding every separator. Disabled, the protocol cannot carry a second
121
+ // statement at all, so a validator bug is not a dropped table.
122
+ multipleStatements: false,
123
+ connectTimeout: this.tuning.connectTimeoutMs,
124
+ // Without this, mysql2 returns Date objects that JSON.stringify converts
125
+ // to UTC ISO strings, silently shifting every timestamp by the server's
126
+ // offset. Strings come back exactly as the server stored them.
127
+ dateStrings: true,
128
+ ssl: this.sslOptions(),
129
+ });
130
+ new MySqlSessionInitializer(this.tuning.queryTimeoutMs, this.logger).attachTo(pool);
131
+ return pool;
132
+ }
133
+ /** `?ssl=true` requires TLS; `?ssl-mode=REQUIRED` is accepted as the MySQL spelling. */
134
+ sslOptions() {
135
+ const mode = (this.target.option("ssl-mode") ?? this.target.option("sslmode") ?? "").toUpperCase();
136
+ if (this.target.flag("ssl") || ["REQUIRED", "REQUIRE"].includes(mode)) {
137
+ return { rejectUnauthorized: false };
138
+ }
139
+ if (["VERIFY_CA", "VERIFY_IDENTITY", "VERIFY-CA", "VERIFY-FULL"].includes(mode)) {
140
+ return { rejectUnauthorized: true };
141
+ }
142
+ return undefined;
143
+ }
144
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Applies the server-side half of the read-only guarantee to every connection
3
+ * a MySQL or MariaDB pool opens.
4
+ *
5
+ * Separated from the driver because it is the security-critical part and
6
+ * deserves to be findable on its own. The driver decides *when* connections
7
+ * exist; this decides *what they are allowed to do*.
8
+ *
9
+ * This is the layer that holds if the SQL validator is ever wrong. The two are
10
+ * independent by design: a parser bug should not automatically be a write.
11
+ */
12
+ export class MySqlSessionInitializer {
13
+ queryTimeoutMs;
14
+ logger;
15
+ /**
16
+ * Removes the two sql_mode flags that change how quotes are read, so the
17
+ * server lexes a statement exactly as the validator did.
18
+ *
19
+ * With NO_BACKSLASH_ESCAPES, `'a\'` is a complete string to the server but
20
+ * an unfinished one to the validator. With ANSI_QUOTES, `"..."` is an
21
+ * identifier, which takes no backslash escapes. Either mismatch lets text
22
+ * the validator took for a literal run as SQL. ANSI and the old combination
23
+ * modes are removed too, because each re-enables ANSI_QUOTES.
24
+ *
25
+ * One statement, built from string functions, because the pool may hand the
26
+ * connection out as soon as this event returns; a read followed by a
27
+ * separate write would race the first query.
28
+ */
29
+ static ALIGN_SQL_MODE = `SET SESSION sql_mode = TRIM(BOTH ',' FROM ${[
30
+ "NO_BACKSLASH_ESCAPES",
31
+ "ANSI_QUOTES",
32
+ "ANSI",
33
+ "DB2",
34
+ "MAXDB",
35
+ "MSSQL",
36
+ "ORACLE",
37
+ "POSTGRESQL",
38
+ ].reduce((expression, mode) => `REPLACE(${expression}, ',${mode},', ',')`, "CONCAT(',', @@SESSION.sql_mode, ',')")})`;
39
+ /** MariaDB's name for the variable; MySQL rejects it with error 1193. */
40
+ static UNKNOWN_VARIABLE = 1193;
41
+ constructor(queryTimeoutMs, logger) {
42
+ this.queryTimeoutMs = queryTimeoutMs;
43
+ this.logger = logger;
44
+ }
45
+ /**
46
+ * Hooks the pool's `connection` event, which fires once per physical
47
+ * connection. Once per connection rather than once per query, and it also
48
+ * covers connections opened later as the pool grows.
49
+ */
50
+ attachTo(pool) {
51
+ pool.on("connection", (connection) => {
52
+ const coreConnection = connection;
53
+ // SET SESSION TRANSACTION READ ONLY sets the access mode for subsequent
54
+ // transactions. With autocommit on, every statement is its own
55
+ // transaction, so MySQL rejects any write with error 1792. It cannot be
56
+ // undone from a query: SET is not an allowed leading keyword, and
57
+ // statement stacking is impossible with multipleStatements disabled.
58
+ this.apply(coreConnection, "SET SESSION TRANSACTION READ ONLY", "set read-only session");
59
+ this.apply(coreConnection, MySqlSessionInitializer.ALIGN_SQL_MODE, "align sql_mode");
60
+ this.applyTimeout(coreConnection);
61
+ });
62
+ }
63
+ /**
64
+ * Caps SELECT execution server-side so a runaway query is killed by the
65
+ * server instead of hanging the conversation. MySQL calls it
66
+ * MAX_EXECUTION_TIME in milliseconds, MariaDB max_statement_time in seconds.
67
+ *
68
+ * On MariaDB the fallback is queued only after MySQL's name is refused, so
69
+ * the very first query on a new connection can run before it applies. That
70
+ * gap affects only the timeout, never the read-only mode set above.
71
+ */
72
+ applyTimeout(connection) {
73
+ connection.query(`SET SESSION MAX_EXECUTION_TIME = ${this.queryTimeoutMs}`, (error) => {
74
+ if (!error) {
75
+ return;
76
+ }
77
+ if (error.errno === MySqlSessionInitializer.UNKNOWN_VARIABLE) {
78
+ this.apply(connection, `SET SESSION max_statement_time = ${this.queryTimeoutMs / 1000}`, "set statement timeout");
79
+ return;
80
+ }
81
+ this.logger(`could not set statement timeout: ${String(error)}`);
82
+ });
83
+ }
84
+ /**
85
+ * Failures are logged, never thrown.
86
+ *
87
+ * A pool `connection` event handler has nowhere to propagate a rejection to,
88
+ * so an unhandled one would take the process down. A connection that could
89
+ * not be set read-only is still guarded by the validator, so continuing is
90
+ * correct; crashing on a server variant lacking one of these variables is
91
+ * not.
92
+ */
93
+ apply(connection, sql, description) {
94
+ connection.query(sql, (error) => {
95
+ if (error) {
96
+ this.logger(`could not ${description}: ${String(error)}`);
97
+ }
98
+ });
99
+ }
100
+ }
@@ -0,0 +1,176 @@
1
+ import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
2
+ import { BaseDriver } from "../BaseDriver.js";
3
+ import { GlobPattern } from "../GlobPattern.js";
4
+ import { LazyResource } from "../LazyResource.js";
5
+ import { SqlIdentifier } from "./SqlIdentifier.js";
6
+ /**
7
+ * PostgreSQL, and anything wire-compatible with it, through node-postgres.
8
+ *
9
+ * Read-only layer two: **every statement runs inside a transaction opened
10
+ * READ ONLY, and that transaction is always rolled back.** PostgreSQL refuses
11
+ * any write inside it, and whatever a statement manages to change without
12
+ * writing (a `set_config` smuggled through a function, say) is undone by the
13
+ * rollback before the connection returns to the pool.
14
+ *
15
+ * This is done per transaction rather than with connection startup options
16
+ * such as `-c default_transaction_read_only=on`, because PgBouncer and most
17
+ * managed poolers reject startup options, and a read-only guarantee that only
18
+ * works without a pooler is not one.
19
+ *
20
+ * The statement itself goes over the extended query protocol, which carries
21
+ * exactly one statement, so a second one cannot be stacked behind it whatever
22
+ * the validator concluded.
23
+ */
24
+ export class PostgresDriver extends BaseDriver {
25
+ tuning;
26
+ family = "sql";
27
+ dialect = "postgres";
28
+ /**
29
+ * Date and time types come back as the text PostgreSQL sent. Parsed into
30
+ * Date objects they would be re-serialised as UTC ISO strings, silently
31
+ * shifting every value without a time zone by the host's offset.
32
+ */
33
+ static RAW_TEXT_TYPES = new Set([1082, 1083, 1114, 1184, 1266]);
34
+ pool;
35
+ constructor(target, tuning) {
36
+ super(target);
37
+ this.tuning = tuning;
38
+ this.pool = new LazyResource(() => this.createPool(), (pool) => pool.end());
39
+ }
40
+ async verify() {
41
+ await this.run("SELECT 1");
42
+ }
43
+ close() {
44
+ return this.pool.close();
45
+ }
46
+ /** Templates are system databases; connections are refused to some, so those are left out. */
47
+ async listDatabases() {
48
+ const rows = (await this.run("SELECT datname AS name, datistemplate AS system FROM pg_database WHERE datallowconn ORDER BY datname"));
49
+ return rows.map((row) => ({ name: row.name, system: row.system }));
50
+ }
51
+ /**
52
+ * Tables in `public` are listed bare and everything else schema-qualified,
53
+ * which is how they are then written in a query.
54
+ */
55
+ async listObjects(pattern, limit) {
56
+ const rows = (await this.run(`SELECT table_schema, table_name FROM information_schema.tables
57
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_schema NOT LIKE 'pg_toast%'
58
+ ORDER BY table_schema, table_name`));
59
+ const names = rows.map((row) => row.table_schema === "public" ? row.table_name : `${row.table_schema}.${row.table_name}`);
60
+ return new GlobPattern(pattern).apply(names, limit);
61
+ }
62
+ async describeObject(name) {
63
+ const qualified = SqlIdentifier.parse(name);
64
+ const rows = await this.run(`SELECT column_name, data_type, is_nullable, column_default, character_maximum_length
65
+ FROM information_schema.columns
66
+ WHERE table_schema = COALESCE($1::text, current_schema()) AND table_name = $2
67
+ ORDER BY ordinal_position`, [qualified.schema, qualified.name]);
68
+ if (rows.length === 0) {
69
+ throw new ObjectNotFoundError(this.objectNoun, name);
70
+ }
71
+ return rows;
72
+ }
73
+ async listIndexes(name) {
74
+ const qualified = SqlIdentifier.parse(name);
75
+ await this.assertExists(name);
76
+ return this.run(`SELECT indexname, indexdef FROM pg_indexes
77
+ WHERE schemaname = COALESCE($1::text, current_schema()) AND tablename = $2
78
+ ORDER BY indexname`, [qualified.schema, qualified.name]);
79
+ }
80
+ async listForeignKeys(name) {
81
+ const qualified = SqlIdentifier.parse(name);
82
+ await this.assertExists(name);
83
+ return this.run(`SELECT c.conname AS constraint_name, pg_get_constraintdef(c.oid) AS definition
84
+ FROM pg_constraint c
85
+ JOIN pg_class t ON t.oid = c.conrelid
86
+ JOIN pg_namespace n ON n.oid = t.relnamespace
87
+ WHERE c.contype = 'f' AND n.nspname = COALESCE($1::text, current_schema()) AND t.relname = $2
88
+ ORDER BY c.conname`, [qualified.schema, qualified.name]);
89
+ }
90
+ async sample(name, limit) {
91
+ const table = SqlIdentifier.quoteQualified(SqlIdentifier.parse(name), SqlIdentifier.doubleQuote);
92
+ return this.run(`SELECT * FROM ${table} LIMIT $1`, [limit]);
93
+ }
94
+ query(sql) {
95
+ return this.run(sql);
96
+ }
97
+ /**
98
+ * One statement inside a read-only transaction that is always rolled back.
99
+ *
100
+ * The opening batch is a single round trip. standard_conforming_strings is
101
+ * pinned on because the SQL validator lexes plain strings without backslash
102
+ * escapes, and that is only how PostgreSQL reads them while it is on.
103
+ */
104
+ async run(text, values) {
105
+ const pool = await this.pool.get();
106
+ const client = await pool.connect();
107
+ let broken;
108
+ try {
109
+ await client.query(`BEGIN TRANSACTION READ ONLY; SET LOCAL statement_timeout = ${this.tuning.queryTimeoutMs}; SET LOCAL standard_conforming_strings = on`);
110
+ const config = { text, values, queryMode: "extended" };
111
+ const result = await client.query(config);
112
+ return result.rows;
113
+ }
114
+ finally {
115
+ broken = await this.rollback(client);
116
+ // A connection whose rollback failed is in an unknown state, so it is
117
+ // destroyed rather than handed to the next query.
118
+ client.release(broken);
119
+ }
120
+ }
121
+ async rollback(client) {
122
+ try {
123
+ await client.query("ROLLBACK");
124
+ return undefined;
125
+ }
126
+ catch (error) {
127
+ return error instanceof Error ? error : new Error(String(error));
128
+ }
129
+ }
130
+ async assertExists(name) {
131
+ const qualified = SqlIdentifier.parse(name);
132
+ const rows = await this.run(`SELECT 1 FROM information_schema.tables
133
+ WHERE table_schema = COALESCE($1::text, current_schema()) AND table_name = $2`, [qualified.schema, qualified.name]);
134
+ if (rows.length === 0) {
135
+ throw new ObjectNotFoundError(this.objectNoun, name);
136
+ }
137
+ }
138
+ /** Imported on first use; see MySqlDriver.createPool. */
139
+ async createPool() {
140
+ const { default: pg } = await import("pg");
141
+ const pool = new pg.Pool({
142
+ host: this.target.host,
143
+ port: this.target.port,
144
+ user: this.target.user || undefined,
145
+ password: this.target.password || undefined,
146
+ database: this.target.database || undefined,
147
+ max: this.tuning.connectionLimit,
148
+ connectionTimeoutMillis: this.tuning.connectTimeoutMs,
149
+ application_name: "mcp-db-read-only",
150
+ ssl: this.sslOptions(),
151
+ types: {
152
+ getTypeParser: ((oid, format) => PostgresDriver.RAW_TEXT_TYPES.has(oid)
153
+ ? (value) => value
154
+ : pg.types.getTypeParser(oid, format)),
155
+ },
156
+ });
157
+ // An idle client that loses its connection emits on the pool; without a
158
+ // listener that is an unhandled error and the whole process exits.
159
+ pool.on("error", () => undefined);
160
+ return pool;
161
+ }
162
+ /**
163
+ * libpq's sslmode names. `require` encrypts without checking the
164
+ * certificate, as libpq does; the verify modes check it.
165
+ */
166
+ sslOptions() {
167
+ const mode = (this.target.option("sslmode") ?? "").toLowerCase();
168
+ if (mode === "verify-ca" || mode === "verify-full") {
169
+ return { rejectUnauthorized: true };
170
+ }
171
+ if (mode === "require" || this.target.flag("ssl")) {
172
+ return { rejectUnauthorized: false };
173
+ }
174
+ return false;
175
+ }
176
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Splits and quotes identifiers for interpolation into SQL.
3
+ *
4
+ * Names reach here already checked by SqlNamePolicy, whose allowlist cannot
5
+ * contain a quote character. The quoting below still escapes one properly, by
6
+ * doubling it, so safety does not rest on the allowlist alone: either
7
+ * protection is enough on its own.
8
+ */
9
+ export class SqlIdentifier {
10
+ /** `schema.table` becomes both parts; `table` has no schema. */
11
+ static parse(value) {
12
+ const dot = value.indexOf(".");
13
+ if (dot === -1) {
14
+ return { schema: null, name: value };
15
+ }
16
+ return { schema: value.slice(0, dot), name: value.slice(dot + 1) };
17
+ }
18
+ /** `name` with backticks, for MySQL, SQLite and ClickHouse. */
19
+ static backtick(part) {
20
+ return `\`${part.replace(/`/g, "``")}\``;
21
+ }
22
+ /** `"name"`, standard SQL, for PostgreSQL and SQLite. */
23
+ static doubleQuote(part) {
24
+ return `"${part.replace(/"/g, '""')}"`;
25
+ }
26
+ /** `[name]`, for SQL Server. */
27
+ static bracket(part) {
28
+ return `[${part.replace(/]/g, "]]")}]`;
29
+ }
30
+ /** Quote each part of a possibly qualified name with the given style. */
31
+ static quoteQualified(qualified, quote) {
32
+ return qualified.schema
33
+ ? `${quote(qualified.schema)}.${quote(qualified.name)}`
34
+ : quote(qualified.name);
35
+ }
36
+ }
@@ -0,0 +1,202 @@
1
+ import { fork } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { ObjectNotFoundError } from "../../errors/ObjectNotFoundError.js";
4
+ import { BaseDriver } from "../BaseDriver.js";
5
+ import { GlobPattern } from "../GlobPattern.js";
6
+ import { LazyResource } from "../LazyResource.js";
7
+ import { SqlIdentifier } from "./SqlIdentifier.js";
8
+ /**
9
+ * The server's side of one SQLite worker process: sends a statement, awaits
10
+ * the rows, and kills the process when a statement overruns.
11
+ */
12
+ class SqliteWorkerHandle {
13
+ child;
14
+ static ENTRY = fileURLToPath(new URL("./SqliteWorker.js", import.meta.url));
15
+ nextId = 1;
16
+ pending = new Map();
17
+ deadReason = null;
18
+ constructor(child) {
19
+ this.child = child;
20
+ child.on("message", (response) => this.settle(response));
21
+ child.on("error", (error) => this.fail(error.message));
22
+ child.on("exit", () => this.fail("the SQLite worker exited"));
23
+ }
24
+ /**
25
+ * Resolves once the database is open, so a missing file or a permissions
26
+ * problem is reported by connect rather than by the first query.
27
+ */
28
+ static start(options, timeoutMs) {
29
+ const child = fork(SqliteWorkerHandle.ENTRY, [JSON.stringify(options)], {
30
+ // stdout is ignored, never inherited: this process's stdout is the
31
+ // JSON-RPC stream, and one stray byte from the child would corrupt it.
32
+ stdio: ["ignore", "ignore", "inherit", "ipc"],
33
+ // Structured clone rather than JSON, so blobs arrive as bytes rather
34
+ // than as an array of numbers.
35
+ serialization: "advanced",
36
+ // node:sqlite prints an ExperimentalWarning on first use. It is noise in
37
+ // a client's log, and it says nothing an operator can act on.
38
+ execArgv: ["--disable-warning=ExperimentalWarning"],
39
+ });
40
+ return new Promise((resolve, reject) => {
41
+ const timer = setTimeout(() => {
42
+ child.kill("SIGKILL");
43
+ reject(new Error(`Opening the SQLite database took longer than ${timeoutMs} ms.`));
44
+ }, timeoutMs);
45
+ child.once("message", (startup) => {
46
+ clearTimeout(timer);
47
+ if (startup.type === "ready") {
48
+ resolve(new SqliteWorkerHandle(child));
49
+ return;
50
+ }
51
+ child.kill("SIGKILL");
52
+ reject(new Error(`Could not open SQLite database: ${startup.error}`));
53
+ });
54
+ child.once("error", (error) => {
55
+ clearTimeout(timer);
56
+ reject(error);
57
+ });
58
+ });
59
+ }
60
+ get dead() {
61
+ return this.deadReason !== null;
62
+ }
63
+ run(sql, params, timeoutMs) {
64
+ if (this.deadReason) {
65
+ return Promise.reject(new Error(this.deadReason));
66
+ }
67
+ const id = this.nextId++;
68
+ return new Promise((resolve, reject) => {
69
+ const timer = setTimeout(() => {
70
+ // The only way to stop a synchronous SQLite statement. The driver
71
+ // opens a fresh process for the next call.
72
+ this.fail(`Query exceeded ${timeoutMs} ms and was stopped.`);
73
+ this.child.kill("SIGKILL");
74
+ }, timeoutMs);
75
+ this.pending.set(id, {
76
+ resolve: (rows) => {
77
+ clearTimeout(timer);
78
+ resolve(rows);
79
+ },
80
+ reject: (error) => {
81
+ clearTimeout(timer);
82
+ reject(error);
83
+ },
84
+ });
85
+ this.child.send({ id, sql, params });
86
+ });
87
+ }
88
+ /**
89
+ * Not awaited beyond the signal: SIGKILL cannot be refused, and waiting on
90
+ * the exit event is what hung the worker-thread version of this class.
91
+ */
92
+ async terminate() {
93
+ this.fail("the SQLite connection was closed");
94
+ this.child.kill("SIGKILL");
95
+ }
96
+ settle(response) {
97
+ const waiter = this.pending.get(response.id);
98
+ if (!waiter) {
99
+ return;
100
+ }
101
+ this.pending.delete(response.id);
102
+ if ("error" in response) {
103
+ waiter.reject(new Error(response.error));
104
+ return;
105
+ }
106
+ waiter.resolve(response.rows);
107
+ }
108
+ /** Every call in flight fails with the same reason, and so does every later one. */
109
+ fail(reason) {
110
+ if (!this.deadReason) {
111
+ this.deadReason = reason;
112
+ }
113
+ for (const waiter of this.pending.values()) {
114
+ waiter.reject(new Error(reason));
115
+ }
116
+ this.pending.clear();
117
+ }
118
+ }
119
+ /**
120
+ * SQLite, through the node:sqlite module built into Node 22.13 and later.
121
+ *
122
+ * Read-only layer two: the file is opened with SQLite's `readOnly` flag, so
123
+ * the library refuses to write to it at all, whatever the statement says.
124
+ * Extension loading is disabled.
125
+ *
126
+ * The database lives in a child process (see SqliteWorker), started on first
127
+ * use and replaced after a query is killed for overrunning.
128
+ *
129
+ * No dependency is needed, which is also why SQLite needs no native module
130
+ * build and works in the Alpine image on every architecture.
131
+ */
132
+ export class SqliteDriver extends BaseDriver {
133
+ tuning;
134
+ family = "sql";
135
+ dialect = "sqlite";
136
+ worker;
137
+ constructor(target, tuning) {
138
+ super(target);
139
+ this.tuning = tuning;
140
+ this.worker = new LazyResource(() => SqliteWorkerHandle.start({ path: target.database, busyTimeoutMs: tuning.connectTimeoutMs }, tuning.connectTimeoutMs), (handle) => handle.terminate());
141
+ }
142
+ async verify() {
143
+ await this.run("SELECT 1");
144
+ }
145
+ close() {
146
+ return this.worker.close();
147
+ }
148
+ async listDatabases() {
149
+ const rows = (await this.run("SELECT name FROM pragma_database_list"));
150
+ return rows.map((row) => ({ name: row.name, system: row.name === "temp" }));
151
+ }
152
+ async listObjects(pattern, limit) {
153
+ const rows = (await this.run("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name"));
154
+ return new GlobPattern(pattern).apply(rows.map((row) => row.name), limit);
155
+ }
156
+ async describeObject(name) {
157
+ const rows = await this.pragma("table_info", name);
158
+ if (rows.length === 0) {
159
+ throw new ObjectNotFoundError(this.objectNoun, name);
160
+ }
161
+ return rows;
162
+ }
163
+ async listIndexes(name) {
164
+ await this.describeObject(name);
165
+ const qualified = SqlIdentifier.parse(name);
166
+ return this.run(`SELECT il.name AS index_name, il."unique" AS is_unique, il.origin, ii.seqno, ii.name AS column_name
167
+ FROM pragma_index_list(?, ?) il JOIN pragma_index_info(il.name, ?) ii
168
+ ORDER BY il.name, ii.seqno`, [qualified.name, qualified.schema ?? "main", qualified.schema ?? "main"]);
169
+ }
170
+ async listForeignKeys(name) {
171
+ await this.describeObject(name);
172
+ return this.pragma("foreign_key_list", name);
173
+ }
174
+ async sample(name, limit) {
175
+ const table = SqlIdentifier.quoteQualified(SqlIdentifier.parse(name), SqlIdentifier.doubleQuote);
176
+ return this.run(`SELECT * FROM ${table} LIMIT ?`, [limit]);
177
+ }
178
+ query(sql) {
179
+ return this.run(sql);
180
+ }
181
+ /** Table-valued pragma functions, so the table name is a bound value, not SQL. */
182
+ pragma(pragmaName, name) {
183
+ const qualified = SqlIdentifier.parse(name);
184
+ return this.run(`SELECT * FROM pragma_${pragmaName}(?, ?)`, [
185
+ qualified.name,
186
+ qualified.schema ?? "main",
187
+ ]);
188
+ }
189
+ /** A worker that died, including one stopped for overrunning, is replaced on the next call. */
190
+ async run(sql, params = []) {
191
+ const handle = await this.worker.get();
192
+ try {
193
+ return await handle.run(sql, params, this.tuning.queryTimeoutMs);
194
+ }
195
+ catch (error) {
196
+ if (handle.dead) {
197
+ await this.worker.reset();
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Messages between SqliteDriver and the worker process that owns the database.
3
+ *
4
+ * Kept in their own module so both sides import one definition, rather than
5
+ * two files agreeing on a shape by coincidence.
6
+ */
7
+ export {};