@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
package/README.md ADDED
@@ -0,0 +1,387 @@
1
+ # mcp-db-read-only
2
+
3
+ [![CI](https://github.com/shibbirweb/mcp-db-read-only/actions/workflows/ci.yml/badge.svg)](https://github.com/shibbirweb/mcp-db-read-only/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40shibbirweb%2Fmcp-db-read-only?label=npm&color=cb3837)](https://www.npmjs.com/package/@shibbirweb/mcp-db-read-only)
5
+ [![Docker Hub](https://img.shields.io/docker/v/shibbirweb/mcp-db-read-only?label=docker%20hub&sort=semver)](https://hub.docker.com/r/shibbirweb/mcp-db-read-only)
6
+ [![Image size](https://img.shields.io/docker/image-size/shibbirweb/mcp-db-read-only/latest?style=flat&label=image%20size)](https://hub.docker.com/r/shibbirweb/mcp-db-read-only/tags)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
9
+ An [MCP](https://modelcontextprotocol.io) server that gives an AI assistant **read-only** access to your databases, whichever kind they are, and lets it **switch database, server, credentials and even engine mid-conversation without restarting the client**.
10
+
11
+ | Engine | URL scheme | Queried with |
12
+ | --- | --- | --- |
13
+ | MySQL, MariaDB | `mysql://`, `mariadb://` | `run_query` (SQL) |
14
+ | PostgreSQL (and wire-compatible) | `postgres://`, `postgresql://` | `run_query` (SQL) |
15
+ | SQLite | `sqlite:///path/to/file.db` | `run_query` (SQL) |
16
+ | SQL Server, Azure SQL | `mssql://`, `sqlserver://` | `run_query` (T-SQL) |
17
+ | ClickHouse | `clickhouse://`, `clickhouse+https://` | `run_query` (SQL) |
18
+ | MongoDB | `mongodb://`, `mongodb+srv://` | `find_documents`, `aggregate`, `count_documents`, `distinct_values` |
19
+ | Redis (and Valkey, KeyDB) | `redis://`, `rediss://` | `redis_command` |
20
+ | Elasticsearch, OpenSearch | `elasticsearch://`, `opensearch://`, `+https` variants | `search` |
21
+
22
+ The browse tools (`list_tables`, `describe_table`, `get_table_sample`, ...) work on every engine, in that engine's terms: tables, collections, keys or indices.
23
+
24
+ Runs from npm with `npx`, or entirely in Docker with nothing installed on your machine.
25
+
26
+ ```mermaid
27
+ flowchart LR
28
+ A["AI assistant<br/>Claude Desktop / Claude Code"]
29
+ B["mcp-db-read-only<br/>one process, whole session"]
30
+ C[("PostgreSQL<br/>app")]
31
+ D[("MySQL<br/>legacy")]
32
+ E[("MongoDB<br/>events")]
33
+ F[("Redis<br/>cache")]
34
+ G[("anything reached<br/>with connect")]
35
+
36
+ A <-->|"MCP over stdio"| B
37
+ B -.->|"one driver per target"| C
38
+ B -.->|"one driver per target"| D
39
+ B -.->|"one driver per target"| E
40
+ B -.->|"one driver per target"| F
41
+ B -.->|"opened at runtime"| G
42
+ ```
43
+
44
+ The server lives for the whole session, so the active connection is just state inside it. Switching selects a different driver rather than reconnecting, and switching back reuses a warm one.
45
+
46
+ ---
47
+
48
+ ## Quick start
49
+
50
+ ### npm
51
+
52
+ ```bash
53
+ DB_URL='postgres://readonly:secret@127.0.0.1:5432/my_database' npx -y @shibbirweb/mcp-db-read-only
54
+ ```
55
+
56
+ Requires Node 22.13 or newer. There is no container in the way, so `127.0.0.1` means what you expect.
57
+
58
+ ### Docker
59
+
60
+ ```bash
61
+ docker run -i --rm \
62
+ --add-host host.docker.internal:host-gateway \
63
+ -e DB_URL='postgres://readonly:secret@host.docker.internal:5432/my_database' \
64
+ shibbirweb/mcp-db-read-only
65
+ ```
66
+
67
+ Use `host.docker.internal` to reach a database on the same machine as Docker. Inside the container, `localhost` means the container itself.
68
+
69
+ For SQLite in Docker, mount the file's directory read-only and point at the path inside the container:
70
+
71
+ ```bash
72
+ docker run -i --rm -v "$PWD/data:/data:ro" -e DB_URL='sqlite:///data/app.db' shibbirweb/mcp-db-read-only
73
+ ```
74
+
75
+ The container is the more isolated of the two: the server runs with only what the image and the environment give it. Over npm it runs directly on your machine with your user's access. Both enforce the same read-only guarantees.
76
+
77
+ ### Claude Desktop
78
+
79
+ Add to `claude_desktop_config.json`:
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "databases": {
85
+ "command": "npx",
86
+ "args": ["-y", "@shibbirweb/mcp-db-read-only"],
87
+ "env": {
88
+ "DB_PROFILES": "{\"app\": \"postgres://readonly@127.0.0.1/app\", \"cache\": \"redis://127.0.0.1:6379/0\"}",
89
+ "DB_DEFAULT_PROFILE": "app"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Or the same server in Docker:
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "databases": {
102
+ "command": "docker",
103
+ "args": [
104
+ "run", "-i", "--rm",
105
+ "--add-host", "host.docker.internal:host-gateway",
106
+ "-e", "DB_URL=postgres://readonly:secret@host.docker.internal:5432/app",
107
+ "shibbirweb/mcp-db-read-only"
108
+ ]
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### Claude Code
115
+
116
+ The same shape, in `.mcp.json` at your project root. Either form above works.
117
+
118
+ Restart the client once. After that you never need to restart it to change database.
119
+
120
+ > Credentials in these files sit on disk in plain text. Prefer read-only database accounts, and keep the file out of version control. See [Security](#security).
121
+
122
+ ### Coming from mcp-mysql-read-only
123
+
124
+ This server reads the old `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, `MYSQL_PROFILES` and `MYSQL_DEFAULT_PROFILE` unchanged, so swapping the image or package name is enough. Tool names are the same too; the one change is that `connect` now takes a URL.
125
+
126
+ ---
127
+
128
+ ## Switching connections
129
+
130
+ Just ask. These map onto the connection tools:
131
+
132
+ > "switch to the staging database"
133
+ > "what collections are in the events database?"
134
+ > "connect to the Redis on 10.0.0.5 and show me the session keys"
135
+
136
+ | Want | Restart? |
137
+ | --- | --- |
138
+ | Another database on the same server | No |
139
+ | Another named profile, on any engine | No |
140
+ | A different server, credentials or engine | No |
141
+ | A new permanent profile in `DB_PROFILES` | Yes, once |
142
+
143
+ ```mermaid
144
+ sequenceDiagram
145
+ autonumber
146
+ actor You
147
+ participant A as Assistant
148
+ participant S as MCP server
149
+ participant P as PostgreSQL
150
+ participant M as MongoDB
151
+
152
+ You->>A: "how many signups yesterday?"
153
+ A->>S: run_query(SELECT count(*) ...)
154
+ S->>P: read-only transaction
155
+ P-->>S: 4821
156
+ A-->>You: 4821 signups
157
+
158
+ You->>A: "and how many of them opened the app?"
159
+ A->>S: use_connection(events)
160
+ S->>M: connect + ping
161
+ Note over S: verified, so the switch is committed
162
+ A->>S: count_documents(opens, {...})
163
+ S->>M: countDocuments
164
+ M-->>S: 3907
165
+ A-->>You: 3907 of them
166
+ ```
167
+
168
+ A switch that fails verification is never committed, so the previous connection stays active and the session keeps working.
169
+
170
+ ### Named profiles
171
+
172
+ Define several connections up front with `DB_PROFILES`, a JSON object whose values are URLs, or objects with a separate password:
173
+
174
+ ```json
175
+ {
176
+ "app": "postgres://readonly@db.internal:5432/app",
177
+ "legacy": "mysql://readonly@legacy.internal/shop",
178
+ "events": { "url": "mongodb://reader@mongo.internal/events", "password": "p@ss/w#rd" },
179
+ "cache": "redis://cache.internal:6379/0",
180
+ "logs": "elasticsearch+https://reader@logs.internal:9200",
181
+ "reports": "sqlite:///data/reports.db"
182
+ }
183
+ ```
184
+
185
+ The object form exists because a password inside a URL must be percent-encoded, and one containing `@`, `/` or `#` otherwise splits the URL in the wrong place. A profile that fails to parse is skipped with a warning rather than taking the server down.
186
+
187
+ ### Reaching somewhere not in the profiles
188
+
189
+ The `connect` tool takes a URL (and optionally a separate password) at runtime and keeps it for the rest of the session under an alias. Nothing is written to disk, and no restart is involved.
190
+
191
+ ### URL details
192
+
193
+ | Engine | Notes |
194
+ | --- | --- |
195
+ | MySQL | `?ssl=true` requires TLS; `?ssl-mode=VERIFY_IDENTITY` also checks the certificate |
196
+ | PostgreSQL | `?sslmode=require`, `verify-ca` or `verify-full`, as in libpq |
197
+ | SQLite | `sqlite:///absolute/path.db`; a relative path is resolved once, at startup |
198
+ | SQL Server | Encrypted by default. `?trustServerCertificate=true` for self-signed development servers; `?applicationIntent=ReadOnly` routes to a readable secondary |
199
+ | ClickHouse | The HTTP interface: port 8123, or 8443 with `clickhouse+https` |
200
+ | MongoDB | Replica sets as `mongodb://a:27017,b:27017/db?replicaSet=rs0`; any driver option passes through the query string |
201
+ | Redis | The path is the database number: `redis://host:6379/3` |
202
+ | Elasticsearch | `?api_key=...` authenticates with an API key; it is treated as a secret and never displayed |
203
+
204
+ ---
205
+
206
+ ## Tools
207
+
208
+ ### Connection
209
+
210
+ | Tool | Purpose |
211
+ | --- | --- |
212
+ | `current_connection` | Which engine, server and database is active |
213
+ | `list_connections` | Available profiles and their engines, `*` marks the active one |
214
+ | `list_databases` | Databases on the connected server |
215
+ | `use_database` | Switch database on the current server |
216
+ | `use_connection` | Switch to a named profile, optional `database` override |
217
+ | `connect` | Open any server from a URL, optional `alias` |
218
+
219
+ ### Browsing, on every engine
220
+
221
+ | Tool | SQL engines | MongoDB | Redis | Elasticsearch |
222
+ | --- | --- | --- | --- | --- |
223
+ | `list_tables` | tables and views | collections | keys, by SCAN | indices |
224
+ | `describe_table` | columns | fields inferred from 100 sampled documents | type, TTL, length | mapping |
225
+ | `get_table_indexes` | indexes | indexes | n/a | n/a |
226
+ | `get_foreign_keys` | foreign keys (not ClickHouse) | n/a | n/a | n/a |
227
+ | `get_table_sample` | up to 50 rows | up to 50 documents | the start of the value | up to 50 hits |
228
+
229
+ `list_tables` takes an optional glob `pattern`, such as `user*`, which is how you browse a Redis instance with millions of keys.
230
+
231
+ ### Querying, per engine family
232
+
233
+ | Tool | Engine | Accepts |
234
+ | --- | --- | --- |
235
+ | `run_query` | SQL engines | One read-only statement in the engine's own dialect |
236
+ | `find_documents` | MongoDB | Filter, projection, sort, limit and skip, as Extended JSON |
237
+ | `aggregate` | MongoDB | A pipeline, without `$out` or `$merge` |
238
+ | `count_documents` | MongoDB | A filter |
239
+ | `distinct_values` | MongoDB | A field and an optional filter |
240
+ | `search` | Elasticsearch, OpenSearch | A Query DSL body; `size: 0` with `track_total_hits` counts |
241
+ | `redis_command` | Redis | One read-only command and its arguments |
242
+
243
+ Every tool is advertised all the time, since MCP fixes the tool list at startup while the active engine can change. Calling one against the wrong engine says which tools fit instead.
244
+
245
+ Every reading tool also accepts an optional `database`, applied to that call only, leaving the active connection alone.
246
+
247
+ ---
248
+
249
+ ## Configuration
250
+
251
+ | Variable | Default | Purpose |
252
+ | --- | --- | --- |
253
+ | `DB_URL` | none | One connection, registered as the profile `default` |
254
+ | `DB_PASSWORD` | none | Password for `DB_URL`, so it need not be encoded into the URL |
255
+ | `DB_PROFILES` | none | JSON object of named profiles |
256
+ | `DB_DEFAULT_PROFILE` | none | Which profile starts active |
257
+ | `DB_QUERY_TIMEOUT_MS` | `30000` | Statement timeout, enforced by each server where it can be |
258
+ | `DB_CONNECT_TIMEOUT_MS` | `10000` | Connection timeout |
259
+ | `MYSQL_*` | | The legacy MySQL-only variables, read unchanged. See above |
260
+
261
+ None of these are required: with no configuration at all the server still starts, and the tools tell you to call `connect`.
262
+
263
+ Starting profile: `DB_DEFAULT_PROFILE` (or `MYSQL_DEFAULT_PROFILE`) if it names a real profile, else `default`, else the first one defined.
264
+
265
+ ---
266
+
267
+ ## Security
268
+
269
+ Every engine is kept read-only by **two independent layers**, so a hole in one is not automatically a write. The first layer runs before any connection is used; the second is enforced by the database server itself wherever the engine offers a way, and structurally where it does not.
270
+
271
+ | Engine | Layer one, in this server | Layer two |
272
+ | --- | --- | --- |
273
+ | MySQL, MariaDB | SQL validator | `SET SESSION TRANSACTION READ ONLY` on every connection; the driver cannot send a second statement |
274
+ | PostgreSQL | SQL validator, dialect-aware (dollar quotes, `E''` strings) | Every statement runs in a `READ ONLY` transaction that is always rolled back; extended query protocol, one statement only |
275
+ | SQLite | SQL validator | The file is opened read-only by SQLite; extensions disabled |
276
+ | SQL Server | SQL validator, scanning every statement since T-SQL needs no separators | Every batch runs in a transaction that is always rolled back |
277
+ | ClickHouse | SQL validator, refusing table functions that reach outside the server | ClickHouse's own `readonly` setting on every query |
278
+ | MongoDB | Operator denylist: `$out`, `$merge`, `$function`, `$where` anywhere | Stage allowlist in the driver, which only ever calls read operations |
279
+ | Redis | Command allowlist | The server's own `COMMAND INFO` flags: a command is sent only if Redis itself calls it read-only |
280
+ | Elasticsearch | Search body allowlist; index names cannot address an API | The driver can only reach fixed read endpoints |
281
+
282
+ The SQL validator lexes each dialect exactly as the server will: string literals, quoted identifiers and comments are blanked before any rule looks at the statement, so a keyword or semicolon inside a literal is never mistaken for SQL. Constructs it cannot be certain the server reads the same way, such as nested block comments or MySQL's executable `/*! */` comments, are refused rather than guessed at. Only the dialect's read statements may lead (`SELECT`, `WITH`, and `SHOW`, `DESCRIBE` or `EXPLAIN` where they exist). Functions that write files, reach other servers or run SQL hidden in a string (`INTO OUTFILE`, `lo_export`, `dblink`, `OPENROWSET`, ClickHouse's `url()` and `file()`) are blocked.
283
+
284
+ Integration tests prove layer two separately: they send writes straight to each driver, bypassing every validator, and assert the server refused or undid them.
285
+
286
+ ### What this is not
287
+
288
+ **This is a guard, not a permission system.** It stops an assistant from writing through *this* server. It does not stop anyone holding the same credentials from writing through any other client.
289
+
290
+ **Point it at read-only accounts.** This is the real protection, and on MongoDB and Elasticsearch, whose servers have no read-only session mode, it is the only server-side one:
291
+
292
+ ```sql
293
+ -- MySQL
294
+ CREATE USER 'readonly'@'%' IDENTIFIED BY '...'; GRANT SELECT ON app.* TO 'readonly'@'%';
295
+ -- PostgreSQL
296
+ CREATE ROLE readonly LOGIN PASSWORD '...'; GRANT pg_read_all_data TO readonly;
297
+ ```
298
+
299
+ ```js
300
+ // MongoDB
301
+ db.createUser({ user: "reader", pwd: "...", roles: [{ role: "read", db: "app" }] });
302
+ ```
303
+
304
+ ```text
305
+ # Redis
306
+ ACL SETUSER reader on >... ~* +@read -@dangerous
307
+ ```
308
+
309
+ Other limits worth knowing:
310
+
311
+ - Results are truncated to 100 rows in the tool output. Add a `LIMIT` (or `$limit`) when reading large tables.
312
+ - A column named exactly like a write keyword must be quoted where the validator scans for them: inside `WITH` queries, and in every SQL Server statement.
313
+ - SQLite queries run in a separate process, so one that exceeds the timeout can be killed outright.
314
+
315
+ ---
316
+
317
+ ## Known behaviour
318
+
319
+ **Parallel tool calls.** The active connection is a single piece of process state. If a client issues several tool calls in one batch they are handled concurrently, so a `use_database` batched alongside a query is not guaranteed to land first. When a read must be pinned to a particular database, pass the per-call `database` argument instead.
320
+
321
+ **Shutdown.** The server exits on `SIGINT`/`SIGTERM`, not when stdin closes. Open sockets keep the event loop alive, and stdin reaching EOF only means no further requests were buffered.
322
+
323
+ ---
324
+
325
+ ## Development
326
+
327
+ Everything runs in Docker, so a clone and Docker are the only requirements:
328
+
329
+ ```bash
330
+ git clone https://github.com/shibbirweb/mcp-db-read-only.git
331
+ cd mcp-db-read-only
332
+ ./scripts/test-in-docker.sh # every engine
333
+ ENGINES="postgres redis" ./scripts/test-in-docker.sh # a subset
334
+ ```
335
+
336
+ That starts a throwaway container per engine, builds the test image, runs the full suite against them and tears everything down. Your own databases are never touched.
337
+
338
+ With Node 22.13 or newer installed locally:
339
+
340
+ ```bash
341
+ npm ci
342
+ npm run build
343
+ npm run test:unit # no database needed
344
+ npm test # integration suites skip any engine they cannot reach
345
+ ```
346
+
347
+ The SQLite and handshake suites need no server and always run. The others read `TEST_MYSQL_URL`, `TEST_POSTGRES_URL`, `TEST_MSSQL_URL`, `TEST_CLICKHOUSE_URL`, `TEST_MONGODB_URL`, `TEST_REDIS_URL` and `TEST_ELASTICSEARCH_URL`, and create and drop scratch data named `mcp_test*`, so point them at disposable servers.
348
+
349
+ ### Project structure
350
+
351
+ ```
352
+ src/
353
+ index.ts Entry point
354
+ ApplicationFactory.ts Composition root: the only file that wires things, and the only one naming a driver
355
+ types/ Interfaces and type aliases, one file per concern
356
+ errors/ Named error classes
357
+ domain/ Engine catalog, ConnectionTarget, ConnectionProfile
358
+ config/ Reading configuration from the environment
359
+ connections/ URL parser, target factory, profile registry, connection manager
360
+ drivers/ DatabaseDriver strategy, registry, LRU cache, and sql/ document/ keyvalue/ search/
361
+ validation/ sql/ (dialects, skeletonizer, rules), document/, keyvalue/, search/, names/
362
+ formatting/ Response, row and JSON rendering
363
+ tools/ BaseTool, DatabaseScopedTool, connection/ browse/ sql/ document/ search/ keyvalue/
364
+ server/ McpDbServer
365
+ ```
366
+
367
+ Developer documentation, including why each part is built the way it is, lives in the [wiki](https://github.com/shibbirweb/mcp-db-read-only/wiki) (source in [`docs/wiki/`](docs/wiki/)).
368
+
369
+ ---
370
+
371
+ ## Contributing
372
+
373
+ Pull requests target `master`. CI runs the full suite against every engine, twice (once with MySQL, once with MariaDB), and builds the image for amd64 and arm64. Please keep changes covered by tests, and update `docs/wiki/` when behaviour changes.
374
+
375
+ There is a second copy of this document, [`README.dockerhub.md`](README.dockerhub.md), which is published as the Docker Hub description. Docker Hub renders neither mermaid nor relative links, so that copy uses ASCII diagrams and absolute URLs. **If you change user-facing behaviour here, change it there too.**
376
+
377
+ ## Changelog
378
+
379
+ Release history is in [CHANGELOG.md](CHANGELOG.md).
380
+
381
+ ## Privacy
382
+
383
+ The server sends nothing anywhere except to the databases you point it at: no telemetry, no analytics, nothing written to disk, nothing kept after it exits. What does leave your machine is whatever your assistant reads, since query results become conversation content. [PRIVACY.md](PRIVACY.md) sets out both halves.
384
+
385
+ ## License
386
+
387
+ [MIT](LICENSE) © Md. Shibbir Ahmed
@@ -0,0 +1,144 @@
1
+ import { EnvironmentConfigLoader } from "./config/EnvironmentConfigLoader.js";
2
+ import { PackageVersionLoader } from "./config/PackageVersionLoader.js";
3
+ import { ConnectionManager } from "./connections/ConnectionManager.js";
4
+ import { ConnectionRegistry } from "./connections/ConnectionRegistry.js";
5
+ import { ConnectionTargetFactory } from "./connections/ConnectionTargetFactory.js";
6
+ import { EngineCatalog } from "./domain/Engine.js";
7
+ import { MongoDriver } from "./drivers/document/MongoDriver.js";
8
+ import { DriverCache } from "./drivers/DriverCache.js";
9
+ import { DriverProvider } from "./drivers/DriverProvider.js";
10
+ import { DriverRegistry } from "./drivers/DriverRegistry.js";
11
+ import { RedisDriver } from "./drivers/keyvalue/RedisDriver.js";
12
+ import { ElasticsearchDriver } from "./drivers/search/ElasticsearchDriver.js";
13
+ import { ClickHouseDriver } from "./drivers/sql/ClickHouseDriver.js";
14
+ import { MsSqlDriver } from "./drivers/sql/MsSqlDriver.js";
15
+ import { MySqlDriver } from "./drivers/sql/MySqlDriver.js";
16
+ import { PostgresDriver } from "./drivers/sql/PostgresDriver.js";
17
+ import { SqliteDriver } from "./drivers/sql/SqliteDriver.js";
18
+ import { RowFormatter } from "./formatting/RowFormatter.js";
19
+ import { McpDbServer } from "./server/McpDbServer.js";
20
+ import { DescribeTableTool } from "./tools/browse/DescribeTableTool.js";
21
+ import { GetForeignKeysTool } from "./tools/browse/GetForeignKeysTool.js";
22
+ import { GetTableIndexesTool } from "./tools/browse/GetTableIndexesTool.js";
23
+ import { GetTableSampleTool } from "./tools/browse/GetTableSampleTool.js";
24
+ import { ListTablesTool } from "./tools/browse/ListTablesTool.js";
25
+ import { ConnectTool } from "./tools/connection/ConnectTool.js";
26
+ import { CurrentConnectionTool } from "./tools/connection/CurrentConnectionTool.js";
27
+ import { ListConnectionsTool } from "./tools/connection/ListConnectionsTool.js";
28
+ import { ListDatabasesTool } from "./tools/connection/ListDatabasesTool.js";
29
+ import { UseConnectionTool } from "./tools/connection/UseConnectionTool.js";
30
+ import { UseDatabaseTool } from "./tools/connection/UseDatabaseTool.js";
31
+ import { AggregateTool } from "./tools/document/AggregateTool.js";
32
+ import { CountDocumentsTool } from "./tools/document/CountDocumentsTool.js";
33
+ import { DistinctValuesTool } from "./tools/document/DistinctValuesTool.js";
34
+ import { FindDocumentsTool } from "./tools/document/FindDocumentsTool.js";
35
+ import { RedisCommandTool } from "./tools/keyvalue/RedisCommandTool.js";
36
+ import { QUERY_TOOLS } from "./tools/QueryTools.js";
37
+ import { SearchTool } from "./tools/search/SearchTool.js";
38
+ import { RunQueryTool } from "./tools/sql/RunQueryTool.js";
39
+ import { MongoOperatorGuard } from "./validation/document/MongoOperatorGuard.js";
40
+ import { RedisCommandValidator } from "./validation/keyvalue/RedisCommandValidator.js";
41
+ import { NamePolicyRegistry } from "./validation/names/NamePolicyRegistry.js";
42
+ import { SearchBodyValidator } from "./validation/search/SearchBodyValidator.js";
43
+ import { SqlValidatorRegistry } from "./validation/sql/SqlValidatorRegistry.js";
44
+ /**
45
+ * The composition root: the one place that knows how every part fits together.
46
+ *
47
+ * Every other class takes its collaborators through its constructor and
48
+ * constructs none of them, which is why they can be unit tested without the
49
+ * environment, without a database, and without module-level singletons. All of
50
+ * that wiring has to happen somewhere, and concentrating it here keeps it out
51
+ * of the classes themselves.
52
+ *
53
+ * This is also the only file that names a concrete driver class.
54
+ */
55
+ export class ApplicationFactory {
56
+ configLoader;
57
+ logger;
58
+ versionLoader;
59
+ /** Not per-target: a handful of connections is ample for one assistant. */
60
+ static CONNECTION_LIMIT = 3;
61
+ /**
62
+ * More databases than anyone flips between in a conversation, while bounding
63
+ * total open connections at MAX_DRIVERS * CONNECTION_LIMIT.
64
+ */
65
+ static MAX_DRIVERS = 8;
66
+ constructor(configLoader = new EnvironmentConfigLoader(), logger = (message) => console.error(`[mcp-db-ro] ${message}`), versionLoader = new PackageVersionLoader()) {
67
+ this.configLoader = configLoader;
68
+ this.logger = logger;
69
+ this.versionLoader = versionLoader;
70
+ }
71
+ create() {
72
+ const config = this.configLoader.load();
73
+ // Warnings are collected by the loader and emitted here, so configuration
74
+ // parsing stays pure and testable while the operator still sees problems.
75
+ for (const warning of config.warnings) {
76
+ this.logger(warning);
77
+ }
78
+ const registry = new ConnectionRegistry(config.profiles);
79
+ const selectionWarning = registry.selectInitial(config.defaultProfileName);
80
+ if (selectionWarning) {
81
+ this.logger(selectionWarning);
82
+ }
83
+ this.reportActiveConnection(registry);
84
+ const tuning = {
85
+ connectionLimit: ApplicationFactory.CONNECTION_LIMIT,
86
+ connectTimeoutMs: config.connectTimeoutMs,
87
+ queryTimeoutMs: config.queryTimeoutMs,
88
+ };
89
+ const cache = new DriverCache(this.createDriverRegistry(tuning), ApplicationFactory.MAX_DRIVERS);
90
+ const connections = new ConnectionManager(registry, cache);
91
+ const drivers = new DriverProvider(registry, cache, QUERY_TOOLS);
92
+ const tools = this.createTools(connections, drivers);
93
+ return new McpDbServer(tools, cache, this.logger, this.versionLoader.load());
94
+ }
95
+ /** One factory per engine. None of them does I/O; drivers connect on first use. */
96
+ createDriverRegistry(tuning) {
97
+ return new DriverRegistry()
98
+ .register("mysql", (target) => new MySqlDriver(target, tuning, this.logger))
99
+ .register("postgres", (target) => new PostgresDriver(target, tuning))
100
+ .register("sqlite", (target) => new SqliteDriver(target, tuning))
101
+ .register("mssql", (target) => new MsSqlDriver(target, tuning))
102
+ .register("clickhouse", (target) => new ClickHouseDriver(target, tuning))
103
+ .register("mongodb", (target) => new MongoDriver(target, tuning))
104
+ .register("redis", (target) => new RedisDriver(target, tuning, this.logger))
105
+ .register("elasticsearch", (target) => new ElasticsearchDriver(target, tuning));
106
+ }
107
+ createTools(connections, drivers) {
108
+ const names = new NamePolicyRegistry();
109
+ const targetFactory = new ConnectionTargetFactory();
110
+ const rows = new RowFormatter();
111
+ const mongoGuard = new MongoOperatorGuard();
112
+ const tools = [
113
+ new CurrentConnectionTool(connections),
114
+ new ListConnectionsTool(connections),
115
+ new ListDatabasesTool(drivers),
116
+ new UseDatabaseTool(connections, names),
117
+ new UseConnectionTool(connections, names),
118
+ new ConnectTool(connections, targetFactory, names),
119
+ new ListTablesTool(drivers, names),
120
+ new DescribeTableTool(drivers, names),
121
+ new GetTableIndexesTool(drivers, names),
122
+ new GetForeignKeysTool(drivers, names),
123
+ new GetTableSampleTool(drivers, names),
124
+ new RunQueryTool(drivers, names, new SqlValidatorRegistry(), rows),
125
+ new FindDocumentsTool(drivers, names, mongoGuard, rows),
126
+ new AggregateTool(drivers, names, mongoGuard, rows),
127
+ new CountDocumentsTool(drivers, names, mongoGuard),
128
+ new DistinctValuesTool(drivers, names, mongoGuard, rows),
129
+ new SearchTool(drivers, names, new SearchBodyValidator()),
130
+ new RedisCommandTool(drivers, names, new RedisCommandValidator()),
131
+ ];
132
+ // Each tool is typed by its own argument shape; the server only needs to
133
+ // register them, so they are collected behind the common base type.
134
+ return tools;
135
+ }
136
+ reportActiveConnection(registry) {
137
+ const active = registry.getActiveTarget();
138
+ if (active) {
139
+ this.logger(`active connection: ${registry.getActiveName()} (${EngineCatalog.label(active.engine)}, ${active.describe()})`);
140
+ return;
141
+ }
142
+ this.logger("no connection configured, call the connect tool to set one");
143
+ }
144
+ }