@shibbirweb/mcp-db-read-only 0.1.0 → 0.2.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 (38) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.dockerhub.md +79 -2
  3. package/README.md +78 -1
  4. package/dist/ApplicationFactory.js +111 -14
  5. package/dist/config/EnvironmentConfigLoader.js +58 -0
  6. package/dist/drivers/BaseDriver.js +10 -1
  7. package/dist/drivers/document/MongoDriver.js +27 -30
  8. package/dist/drivers/keyvalue/RedisDriver.js +20 -5
  9. package/dist/drivers/search/ElasticsearchDriver.js +13 -9
  10. package/dist/drivers/sql/ClickHouseDriver.js +12 -13
  11. package/dist/drivers/sql/MsSqlDriver.js +6 -6
  12. package/dist/drivers/sql/MySqlDriver.js +7 -5
  13. package/dist/drivers/sql/MySqlSessionInitializer.js +17 -2
  14. package/dist/drivers/sql/PostgresDriver.js +6 -6
  15. package/dist/drivers/sql/SqliteDriver.js +3 -3
  16. package/dist/formatting/JsonSerializer.js +3 -2
  17. package/dist/logging/CallLogger.js +139 -0
  18. package/dist/logging/LogChannel.js +18 -0
  19. package/dist/logging/LogFormatter.js +80 -0
  20. package/dist/logging/LogRecords.js +7 -0
  21. package/dist/logging/LogSink.js +38 -0
  22. package/dist/logging/RecordJson.js +39 -0
  23. package/dist/logging/Redactor.js +95 -0
  24. package/dist/logging/StatementTracer.js +9 -0
  25. package/dist/logging/ToolCallObserver.js +6 -0
  26. package/dist/logging/store/FolderLogChannel.js +56 -0
  27. package/dist/logging/store/FolderLogStore.js +214 -0
  28. package/dist/logging/store/LogFileNames.js +57 -0
  29. package/dist/logging/store/LogStore.js +18 -0
  30. package/dist/logging/store/MemoryLogStore.js +70 -0
  31. package/dist/logging/viewer/LiveLogViewer.js +256 -0
  32. package/dist/logging/viewer/LiveViewerObserver.js +62 -0
  33. package/dist/logging/viewer/ViewerAssets.js +625 -0
  34. package/dist/server/BackgroundService.js +1 -0
  35. package/dist/server/McpDbServer.js +16 -2
  36. package/dist/tools/BaseTool.js +8 -2
  37. package/dist/tools/connection/CurrentConnectionTool.js +11 -2
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,9 +6,21 @@ Each release is published to npm and Docker Hub from the same tag. Where a versi
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0]
10
+
11
+ Not yet published to npm, Docker Hub or the MCP Registry.
12
+
13
+ ### Added
14
+
15
+ - Optional call logging (`DB_LOG`, `DB_LOG_FILE`, `DB_LOG_FORMAT`): every tool call with its input, each statement sent to the database, and the full output, in a readable boxed format or as JSON lines. Credentials are always redacted, and a logging failure never fails a call.
16
+ - A permanent log folder (`DB_LOG_DIR`): every entry saved as its own pretty JSON file, in a folder per day, never deleted, shared safely by every copy of the server.
17
+ - A live log viewer in the browser (`DB_LOG_PORT`, `DB_LOG_HISTORY`): paginated, 20 per page by default with 10, 20, 30 or 50 to choose from; filters across everything logged; a copy icon on every block; and live updates as each call finishes. With a log folder it shows every copy's calls, across restarts. Off unless a port is set; it has no access control and listens on all interfaces.
18
+ - The viewer binds its port on the first tool call, so the copy of the server actually in use gets it. A busy port is explained once in the chat, naming the process holding it, and retried on every call.
19
+ - `current_connection` reports the viewer's state.
20
+
9
21
  ## [0.1.0]
10
22
 
11
- First release. Not yet published to npm, Docker Hub or the MCP Registry.
23
+ First version. Never published to npm, Docker Hub or the MCP Registry.
12
24
 
13
25
  ### Added
14
26
 
@@ -65,7 +65,7 @@ The server lives for the whole session, so the active connection is just state i
65
65
 
66
66
  ## Supported tags
67
67
 
68
- `0.1.0`, `0.1`, `0`, `latest`, built for `linux/amd64` and `linux/arm64`.
68
+ `0.2.0`, `0.2`, `0`, `latest`, built for `linux/amd64` and `linux/arm64`.
69
69
 
70
70
  ---
71
71
 
@@ -271,6 +271,12 @@ Every reading tool also accepts an optional `database`, applied to that call onl
271
271
  | `DB_DEFAULT_PROFILE` | none | Which profile starts active |
272
272
  | `DB_QUERY_TIMEOUT_MS` | `30000` | Statement timeout, enforced by each server where it can be |
273
273
  | `DB_CONNECT_TIMEOUT_MS` | `10000` | Connection timeout |
274
+ | `DB_LOG` | off | `true` logs every tool call to stderr. See [Call logging](https://github.com/shibbirweb/mcp-db-read-only#call-logging) |
275
+ | `DB_LOG_FILE` | none | Log every tool call to this file instead |
276
+ | `DB_LOG_DIR` | none | Save every tool call as its own JSON file in this folder, permanently |
277
+ | `DB_LOG_FORMAT` | `pretty` | `pretty` or `json` |
278
+ | `DB_LOG_PORT` | none | Serve a live log viewer in the browser on this port |
279
+ | `DB_LOG_HISTORY` | `500` | Entries the viewer keeps in memory when there is no `DB_LOG_DIR` |
274
280
  | `MYSQL_*` | | The legacy MySQL-only variables, read unchanged. See above |
275
281
 
276
282
  None of these are required: with no configuration at all the server still starts, and the tools tell you to call `connect`.
@@ -279,6 +285,77 @@ Starting profile: `DB_DEFAULT_PROFILE` (or `MYSQL_DEFAULT_PROFILE`) if it names
279
285
 
280
286
  ---
281
287
 
288
+ ## Call logging
289
+
290
+ Off by default. Turn it on to see every tool call the assistant makes: its input, each statement the drivers sent to the database, and the full output.
291
+
292
+ | Variable | Effect |
293
+ | --- | --- |
294
+ | `DB_LOG=true` | Log to stderr, which your MCP client keeps (Claude Code shows it in its MCP logs) |
295
+ | `DB_LOG_FILE=/path/calls.log` | Log to that file instead, appended to, created readable by you only. Implies `DB_LOG` |
296
+ | `DB_LOG_DIR=/path/folder` | Save every entry as its own JSON file in that folder, permanently. Implies logging on its own, without also writing text |
297
+ | `DB_LOG_FORMAT=json` | One JSON object per line, for `jq` or a log shipper. The default is `pretty` |
298
+
299
+ A pretty entry:
300
+
301
+ ```text
302
+ ┌─ #3 run_query · ok · 38 ms · 2026-09-25T10:14:03.221Z
303
+ │ connection dev (MySQL) mysql://reader@127.0.0.1:3306/app
304
+ │ input
305
+ │ {
306
+ │ "query": "SELECT COUNT(*) AS n FROM members"
307
+ │ }
308
+ │ statements (1)
309
+ │ 1. MySQL · 12 ms · 1 row
310
+ │ SELECT COUNT(*) AS n FROM members
311
+ │ output
312
+ │ [
313
+ │ {
314
+ │ "n": 17440
315
+ │ }
316
+ │ ]
317
+ └─
318
+ ```
319
+
320
+ - **Everything is logged, including the full output of every call.** Treat a log file like the data it contains.
321
+ - **Credentials never are.** A `password` argument, the password inside a connection URL, and secret-looking URL options such as `api_key` are always written as `***`, with no way to turn that off.
322
+ - **Statements** include the ones the server sends on its own behalf: PostgreSQL's `BEGIN READ ONLY` and `ROLLBACK`, Redis's `COMMAND INFO` checks, the catalog queries behind `describe_table`. Each shows its duration and outcome (a row count, or the error); the data itself is in the call's output. A statement sent outside any call, such as MySQL's per-connection setup, gets an entry of its own.
323
+ - An entry is written when its call finishes, and numbered when it starts, so calls handled concurrently can appear out of numeric order.
324
+ - With `DB_LOG_DIR`, each entry is its own file in a folder per UTC day, e.g. `2026-09-25/103014-221Z_p72440_c000012_run_query_ok.json`, holding the full record as pretty JSON: time, pid, sequence, tool and outcome are in the name, so `ls` and `grep` work without opening anything. Files and folders are readable by you only. Nothing is ever deleted or rotated; archive or remove old day folders yourself. Every copy of the server can share one folder, since names never collide.
325
+ - Logging can never break a call. If the log cannot be written (say, the disk is full), the call still succeeds, one warning is printed, and logging stops.
326
+
327
+ In Docker, a log file or folder must be on a mounted volume to outlive the container: `-v "$PWD/logs:/logs" -e DB_LOG_DIR=/logs`. `DB_LOG=true` needs no mount.
328
+
329
+ ### Live viewer in the browser
330
+
331
+ Add `DB_LOG_PORT` to watch calls arrive in a browser page, updating the moment each one finishes:
332
+
333
+ ```bash
334
+ DB_LOG=true DB_LOG_PORT=4800 npx -y @shibbirweb/mcp-db-read-only
335
+ # then open http://127.0.0.1:4800/
336
+ ```
337
+
338
+ | Variable | Default | Effect |
339
+ | --- | --- | --- |
340
+ | `DB_LOG_PORT` | none | Serve the live viewer on this port. Needs `DB_LOG_DIR`, `DB_LOG` or `DB_LOG_FILE` as well; alone it only prints a warning |
341
+ | `DB_LOG_HISTORY` | `500` | Without `DB_LOG_DIR`, how many recent entries the viewer keeps in memory. `0` keeps none |
342
+
343
+ The page lists calls newest first, **20 per page by default**, with page controls and a choice of 10, 20, 30 or 50 per page (remembered in your browser). Each call is a card (tool, status, duration, connection, and which client and process made it) that expands to its input, statements and output, every block with its own copy icon. Filters (text, tool, failures only) apply across everything logged, not just the page shown. Page 1 updates itself as calls arrive; on any other page a "new entries" button appears instead, so the page you are reading does not shift. It follows your system's light or dark theme and loads nothing from the internet.
344
+
345
+ With `DB_LOG_DIR` the viewer reads the folder, so it shows everything ever saved there, across restarts, **including calls made by other copies of the server** sharing the folder (Claude Desktop runs several). Without it, it shows this copy's last `DB_LOG_HISTORY` calls from memory.
346
+
347
+ > **The viewer has no access control and listens on every network interface (`0.0.0.0`).** Anyone who can reach the port, including other devices on the same network, can read every query and every result in the log. Use it on a network you trust, or block the port at your firewall. The server prints a reminder of this when the viewer starts.
348
+
349
+ With no `DB_LOG_PORT`, nothing listens on any port.
350
+
351
+ The viewer takes its port on the **first tool call**, not at startup. MCP clients such as Claude Desktop start one copy of the server per chat surface, and most copies are never used; binding lazily means the copy your chat is using gets the port, and idle copies hold none (they open no database connections either until used).
352
+
353
+ If the port is taken when a call arrives (another chat is already using the viewer, say), the call still works, and its result carries one extra line saying which process holds the port: free it, or set `DB_LOG_PORT` to another one. Every later call retries, so once the port is free the viewer comes up on the next call and says so. `current_connection` always shows the viewer's state.
354
+
355
+ In Docker, publish the port as well: `-p 4800:4800 -e DB_LOG=true -e DB_LOG_PORT=4800`. Publishing it as `-p 127.0.0.1:4800:4800` keeps it reachable from this machine only.
356
+
357
+ ---
358
+
282
359
  ## Security
283
360
 
284
361
  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.
@@ -333,7 +410,7 @@ Other limits worth knowing:
333
410
 
334
411
  **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.
335
412
 
336
- **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.
413
+ **Shutdown.** The server exits on `SIGINT`/`SIGTERM`, not when stdin closes. The live viewer, if running, closes its port with it. Open sockets keep the event loop alive, and stdin reaching EOF only means no further requests were buffered.
337
414
 
338
415
  ---
339
416
 
package/README.md CHANGED
@@ -256,6 +256,12 @@ Every reading tool also accepts an optional `database`, applied to that call onl
256
256
  | `DB_DEFAULT_PROFILE` | none | Which profile starts active |
257
257
  | `DB_QUERY_TIMEOUT_MS` | `30000` | Statement timeout, enforced by each server where it can be |
258
258
  | `DB_CONNECT_TIMEOUT_MS` | `10000` | Connection timeout |
259
+ | `DB_LOG` | off | `true` logs every tool call to stderr. See [Call logging](#call-logging) |
260
+ | `DB_LOG_FILE` | none | Log every tool call to this file instead |
261
+ | `DB_LOG_DIR` | none | Save every tool call as its own JSON file in this folder, permanently |
262
+ | `DB_LOG_FORMAT` | `pretty` | `pretty` or `json` |
263
+ | `DB_LOG_PORT` | none | Serve a live log viewer in the browser on this port |
264
+ | `DB_LOG_HISTORY` | `500` | Entries the viewer keeps in memory when there is no `DB_LOG_DIR` |
259
265
  | `MYSQL_*` | | The legacy MySQL-only variables, read unchanged. See above |
260
266
 
261
267
  None of these are required: with no configuration at all the server still starts, and the tools tell you to call `connect`.
@@ -264,6 +270,77 @@ Starting profile: `DB_DEFAULT_PROFILE` (or `MYSQL_DEFAULT_PROFILE`) if it names
264
270
 
265
271
  ---
266
272
 
273
+ ## Call logging
274
+
275
+ Off by default. Turn it on to see every tool call the assistant makes: its input, each statement the drivers sent to the database, and the full output.
276
+
277
+ | Variable | Effect |
278
+ | --- | --- |
279
+ | `DB_LOG=true` | Log to stderr, which your MCP client keeps (Claude Code shows it in its MCP logs) |
280
+ | `DB_LOG_FILE=/path/calls.log` | Log to that file instead, appended to, created readable by you only. Implies `DB_LOG` |
281
+ | `DB_LOG_DIR=/path/folder` | Save every entry as its own JSON file in that folder, permanently. Implies logging on its own, without also writing text |
282
+ | `DB_LOG_FORMAT=json` | One JSON object per line, for `jq` or a log shipper. The default is `pretty` |
283
+
284
+ A pretty entry:
285
+
286
+ ```text
287
+ ┌─ #3 run_query · ok · 38 ms · 2026-09-25T10:14:03.221Z
288
+ │ connection dev (MySQL) mysql://reader@127.0.0.1:3306/app
289
+ │ input
290
+ │ {
291
+ │ "query": "SELECT COUNT(*) AS n FROM members"
292
+ │ }
293
+ │ statements (1)
294
+ │ 1. MySQL · 12 ms · 1 row
295
+ │ SELECT COUNT(*) AS n FROM members
296
+ │ output
297
+ │ [
298
+ │ {
299
+ │ "n": 17440
300
+ │ }
301
+ │ ]
302
+ └─
303
+ ```
304
+
305
+ - **Everything is logged, including the full output of every call.** Treat a log file like the data it contains.
306
+ - **Credentials never are.** A `password` argument, the password inside a connection URL, and secret-looking URL options such as `api_key` are always written as `***`, with no way to turn that off.
307
+ - **Statements** include the ones the server sends on its own behalf: PostgreSQL's `BEGIN READ ONLY` and `ROLLBACK`, Redis's `COMMAND INFO` checks, the catalog queries behind `describe_table`. Each shows its duration and outcome (a row count, or the error); the data itself is in the call's output. A statement sent outside any call, such as MySQL's per-connection setup, gets an entry of its own.
308
+ - An entry is written when its call finishes, and numbered when it starts, so calls handled concurrently can appear out of numeric order.
309
+ - With `DB_LOG_DIR`, each entry is its own file in a folder per UTC day, e.g. `2026-09-25/103014-221Z_p72440_c000012_run_query_ok.json`, holding the full record as pretty JSON: time, pid, sequence, tool and outcome are in the name, so `ls` and `grep` work without opening anything. Files and folders are readable by you only. Nothing is ever deleted or rotated; archive or remove old day folders yourself. Every copy of the server can share one folder, since names never collide.
310
+ - Logging can never break a call. If the log cannot be written (say, the disk is full), the call still succeeds, one warning is printed, and logging stops.
311
+
312
+ In Docker, a log file or folder must be on a mounted volume to outlive the container: `-v "$PWD/logs:/logs" -e DB_LOG_DIR=/logs`. `DB_LOG=true` needs no mount.
313
+
314
+ ### Live viewer in the browser
315
+
316
+ Add `DB_LOG_PORT` to watch calls arrive in a browser page, updating the moment each one finishes:
317
+
318
+ ```bash
319
+ DB_LOG=true DB_LOG_PORT=4800 npx -y @shibbirweb/mcp-db-read-only
320
+ # then open http://127.0.0.1:4800/
321
+ ```
322
+
323
+ | Variable | Default | Effect |
324
+ | --- | --- | --- |
325
+ | `DB_LOG_PORT` | none | Serve the live viewer on this port. Needs `DB_LOG_DIR`, `DB_LOG` or `DB_LOG_FILE` as well; alone it only prints a warning |
326
+ | `DB_LOG_HISTORY` | `500` | Without `DB_LOG_DIR`, how many recent entries the viewer keeps in memory. `0` keeps none |
327
+
328
+ The page lists calls newest first, **20 per page by default**, with page controls and a choice of 10, 20, 30 or 50 per page (remembered in your browser). Each call is a card (tool, status, duration, connection, and which client and process made it) that expands to its input, statements and output, every block with its own copy icon. Filters (text, tool, failures only) apply across everything logged, not just the page shown. Page 1 updates itself as calls arrive; on any other page a "new entries" button appears instead, so the page you are reading does not shift. It follows your system's light or dark theme and loads nothing from the internet.
329
+
330
+ With `DB_LOG_DIR` the viewer reads the folder, so it shows everything ever saved there, across restarts, **including calls made by other copies of the server** sharing the folder (Claude Desktop runs several). Without it, it shows this copy's last `DB_LOG_HISTORY` calls from memory.
331
+
332
+ > **The viewer has no access control and listens on every network interface (`0.0.0.0`).** Anyone who can reach the port, including other devices on the same network, can read every query and every result in the log. Use it on a network you trust, or block the port at your firewall. The server prints a reminder of this when the viewer starts.
333
+
334
+ With no `DB_LOG_PORT`, nothing listens on any port.
335
+
336
+ The viewer takes its port on the **first tool call**, not at startup. MCP clients such as Claude Desktop start one copy of the server per chat surface, and most copies are never used; binding lazily means the copy your chat is using gets the port, and idle copies hold none (they open no database connections either until used).
337
+
338
+ If the port is taken when a call arrives (another chat is already using the viewer, say), the call still works, and its result carries one extra line saying which process holds the port: free it, or set `DB_LOG_PORT` to another one. Every later call retries, so once the port is free the viewer comes up on the next call and says so. `current_connection` always shows the viewer's state.
339
+
340
+ In Docker, publish the port as well: `-p 4800:4800 -e DB_LOG=true -e DB_LOG_PORT=4800`. Publishing it as `-p 127.0.0.1:4800:4800` keeps it reachable from this machine only.
341
+
342
+ ---
343
+
267
344
  ## Security
268
345
 
269
346
  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.
@@ -318,7 +395,7 @@ Other limits worth knowing:
318
395
 
319
396
  **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
397
 
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.
398
+ **Shutdown.** The server exits on `SIGINT`/`SIGTERM`, not when stdin closes. The live viewer, if running, closes its port with it. Open sockets keep the event loop alive, and stdin reaching EOF only means no further requests were buffered.
322
399
 
323
400
  ---
324
401
 
@@ -16,6 +16,17 @@ import { MySqlDriver } from "./drivers/sql/MySqlDriver.js";
16
16
  import { PostgresDriver } from "./drivers/sql/PostgresDriver.js";
17
17
  import { SqliteDriver } from "./drivers/sql/SqliteDriver.js";
18
18
  import { RowFormatter } from "./formatting/RowFormatter.js";
19
+ import { CallLogger } from "./logging/CallLogger.js";
20
+ import { TextLogChannel } from "./logging/LogChannel.js";
21
+ import { LiveLogViewer } from "./logging/viewer/LiveLogViewer.js";
22
+ import { LiveViewerObserver } from "./logging/viewer/LiveViewerObserver.js";
23
+ import { FolderLogChannel } from "./logging/store/FolderLogChannel.js";
24
+ import { FolderLogStore } from "./logging/store/FolderLogStore.js";
25
+ import { MemoryLogStore } from "./logging/store/MemoryLogStore.js";
26
+ import { JsonLogFormatter, PrettyLogFormatter } from "./logging/LogFormatter.js";
27
+ import { FileSink, StderrSink } from "./logging/LogSink.js";
28
+ import { SilentTracer } from "./logging/StatementTracer.js";
29
+ import { SilentObserver } from "./logging/ToolCallObserver.js";
19
30
  import { McpDbServer } from "./server/McpDbServer.js";
20
31
  import { DescribeTableTool } from "./tools/browse/DescribeTableTool.js";
21
32
  import { GetForeignKeysTool } from "./tools/browse/GetForeignKeysTool.js";
@@ -63,6 +74,11 @@ export class ApplicationFactory {
63
74
  * total open connections at MAX_DRIVERS * CONNECTION_LIMIT.
64
75
  */
65
76
  static MAX_DRIVERS = 8;
77
+ /**
78
+ * Set once built, for the call log's client name: the logger has to exist
79
+ * before the server that will learn the name at handshake.
80
+ */
81
+ server = null;
66
82
  constructor(configLoader = new EnvironmentConfigLoader(), logger = (message) => console.error(`[mcp-db-ro] ${message}`), versionLoader = new PackageVersionLoader()) {
67
83
  this.configLoader = configLoader;
68
84
  this.logger = logger;
@@ -86,31 +102,112 @@ export class ApplicationFactory {
86
102
  connectTimeoutMs: config.connectTimeoutMs,
87
103
  queryTimeoutMs: config.queryTimeoutMs,
88
104
  };
89
- const cache = new DriverCache(this.createDriverRegistry(tuning), ApplicationFactory.MAX_DRIVERS);
105
+ const callLog = this.createCallLog(config.logging, registry);
106
+ const cache = new DriverCache(this.createDriverRegistry(tuning, callLog.tracer), ApplicationFactory.MAX_DRIVERS);
90
107
  const connections = new ConnectionManager(registry, cache);
91
108
  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());
109
+ const tools = this.createTools(connections, drivers, callLog.viewerStatus);
110
+ const server = new McpDbServer(tools, cache, this.logger, callLog.observer, callLog.services, this.versionLoader.load());
111
+ this.server = server;
112
+ return server;
113
+ }
114
+ /**
115
+ * The call log when DB_LOG or DB_LOG_FILE asks for it, silent stand-ins
116
+ * otherwise. One CallLogger plays both roles, observer for the tools and
117
+ * tracer for the drivers, which is what lets it nest each driver statement
118
+ * under the call that caused it.
119
+ */
120
+ createCallLog(settings, registry) {
121
+ if (!settings.enabled) {
122
+ return { observer: new SilentObserver(), tracer: new SilentTracer(), services: [], viewerStatus: () => null };
123
+ }
124
+ const channels = [];
125
+ const services = [];
126
+ let viewer = null;
127
+ const outputs = [];
128
+ const sink = settings.file ? new FileSink(settings.file) : new StderrSink();
129
+ if (settings.text) {
130
+ const formatter = settings.format === "json" ? new JsonLogFormatter() : new PrettyLogFormatter();
131
+ channels.push(new TextLogChannel(sink, formatter));
132
+ outputs.push(`${settings.format} text to ${sink.description}`);
133
+ }
134
+ // The viewer reads the folder when there is one, which also shows the
135
+ // calls of every other copy of the server saving there, and otherwise
136
+ // keeps this process's recent entries in memory.
137
+ let store;
138
+ if (settings.directory) {
139
+ const folderStore = new FolderLogStore(settings.directory);
140
+ store = folderStore;
141
+ channels.push(new FolderLogChannel(settings.directory, (entry) => folderStore.noteWritten(entry)));
142
+ outputs.push(`one JSON file per entry in ${settings.directory}`);
143
+ }
144
+ else {
145
+ const memoryStore = new MemoryLogStore(settings.viewerHistory);
146
+ store = memoryStore;
147
+ if (settings.viewerPort !== null) {
148
+ channels.push(memoryStore);
149
+ }
150
+ }
151
+ if (settings.viewerPort !== null) {
152
+ // Always all interfaces, as configured: reachable from other machines
153
+ // and from a Docker host without extra settings. The viewer announces
154
+ // that it has no access control when it starts, which is on the first
155
+ // tool call rather than here.
156
+ viewer = new LiveLogViewer("0.0.0.0", settings.viewerPort, store, this.logger);
157
+ services.push(viewer);
158
+ }
159
+ const logger = new CallLogger(channels, () => {
160
+ const target = registry.getActiveTarget();
161
+ return target
162
+ ? `${registry.getActiveName()} (${EngineCatalog.label(target.engine)}) ${target.describe()}`
163
+ : null;
164
+ }, this.logger, () => this.clientName());
165
+ // Said once at startup, because with logging on every query and every
166
+ // result is being written somewhere, and the operator should know where.
167
+ this.logger(`call logging on: every tool call, its statements and its full output are written as ${outputs.join(" and ")}`);
168
+ const fallback = outputs.length > 0 ? outputs.join(" and ") : "nowhere else";
169
+ const observer = viewer ? new LiveViewerObserver(logger, viewer, fallback, this.logger) : logger;
170
+ return {
171
+ observer,
172
+ tracer: logger,
173
+ services,
174
+ viewerStatus: () => (viewer ? this.describeViewer(viewer) : null),
175
+ };
176
+ }
177
+ clientName() {
178
+ return this.server?.clientName() ?? null;
179
+ }
180
+ /** One line for current_connection. */
181
+ describeViewer(viewer) {
182
+ const status = viewer.status;
183
+ switch (status.state) {
184
+ case "running":
185
+ return `running at ${status.url}`;
186
+ case "unavailable":
187
+ return `unavailable, ${status.reason} (free it or set DB_LOG_PORT to another port)`;
188
+ case "idle":
189
+ return "starts on the first tool call";
190
+ }
94
191
  }
95
192
  /** One factory per engine. None of them does I/O; drivers connect on first use. */
96
- createDriverRegistry(tuning) {
193
+ createDriverRegistry(tuning, tracer) {
97
194
  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));
195
+ .register("mysql", (target) => new MySqlDriver(target, tuning, tracer, this.logger))
196
+ .register("postgres", (target) => new PostgresDriver(target, tuning, tracer))
197
+ .register("sqlite", (target) => new SqliteDriver(target, tuning, tracer))
198
+ .register("mssql", (target) => new MsSqlDriver(target, tuning, tracer))
199
+ .register("clickhouse", (target) => new ClickHouseDriver(target, tuning, tracer))
200
+ .register("mongodb", (target) => new MongoDriver(target, tuning, tracer))
201
+ .register("redis", (target) => new RedisDriver(target, tuning, tracer, this.logger))
202
+ .register("elasticsearch", (target) => new ElasticsearchDriver(target, tuning, tracer));
106
203
  }
107
- createTools(connections, drivers) {
204
+ createTools(connections, drivers, viewerStatus) {
108
205
  const names = new NamePolicyRegistry();
109
206
  const targetFactory = new ConnectionTargetFactory();
110
207
  const rows = new RowFormatter();
111
208
  const mongoGuard = new MongoOperatorGuard();
112
209
  const tools = [
113
- new CurrentConnectionTool(connections),
210
+ new CurrentConnectionTool(connections, viewerStatus),
114
211
  new ListConnectionsTool(connections),
115
212
  new ListDatabasesTool(drivers),
116
213
  new UseDatabaseTool(connections, names),
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { ConnectionProfile } from "../domain/ConnectionProfile.js";
2
3
  import { ConnectionTargetFactory } from "../connections/ConnectionTargetFactory.js";
3
4
  /**
@@ -30,6 +31,7 @@ export class EnvironmentConfigLoader {
30
31
  targetFactory;
31
32
  static DEFAULT_QUERY_TIMEOUT_MS = 30000;
32
33
  static DEFAULT_CONNECT_TIMEOUT_MS = 10000;
34
+ static DEFAULT_VIEWER_HISTORY = 500;
33
35
  constructor(env = process.env, targetFactory = new ConnectionTargetFactory()) {
34
36
  this.env = env;
35
37
  this.targetFactory = targetFactory;
@@ -46,9 +48,65 @@ export class EnvironmentConfigLoader {
46
48
  defaultProfileName: this.env.DB_DEFAULT_PROFILE || this.env.MYSQL_DEFAULT_PROFILE || null,
47
49
  queryTimeoutMs: this.readTimeout(this.env.DB_QUERY_TIMEOUT_MS ?? this.env.MYSQL_QUERY_TIMEOUT_MS, EnvironmentConfigLoader.DEFAULT_QUERY_TIMEOUT_MS),
48
50
  connectTimeoutMs: this.readTimeout(this.env.DB_CONNECT_TIMEOUT_MS ?? this.env.MYSQL_CONNECT_TIMEOUT_MS, EnvironmentConfigLoader.DEFAULT_CONNECT_TIMEOUT_MS),
51
+ logging: this.readLogging(warnings),
49
52
  warnings,
50
53
  };
51
54
  }
55
+ /**
56
+ * DB_LOG=true logs text to stderr; DB_LOG_FILE=/path logs text to that
57
+ * file. DB_LOG_DIR=/folder saves every entry as its own JSON file there.
58
+ * Each of the three turns logging on by itself, and they combine.
59
+ * DB_LOG_FORMAT picks pretty (the default) or json for the text output.
60
+ *
61
+ * Relative paths are made absolute here, against the directory the server
62
+ * was started in, so the startup message names the real location.
63
+ */
64
+ readLogging(warnings) {
65
+ const file = this.env.DB_LOG_FILE ? resolve(this.env.DB_LOG_FILE) : null;
66
+ const directory = this.env.DB_LOG_DIR ? resolve(this.env.DB_LOG_DIR) : null;
67
+ const stderr = ["true", "1", "yes", "on"].includes((this.env.DB_LOG ?? "").toLowerCase());
68
+ const text = stderr || file !== null;
69
+ const enabled = text || directory !== null;
70
+ const requested = (this.env.DB_LOG_FORMAT ?? "").toLowerCase();
71
+ let format = "pretty";
72
+ if (requested === "json") {
73
+ format = "json";
74
+ }
75
+ else if (requested && requested !== "pretty") {
76
+ warnings.push(`DB_LOG_FORMAT="${this.env.DB_LOG_FORMAT}" is not pretty or json, using pretty.`);
77
+ }
78
+ return { enabled, text, file, directory, format, ...this.readViewer(enabled, warnings) };
79
+ }
80
+ /**
81
+ * DB_LOG_PORT starts the live browser viewer, but only while logging is on:
82
+ * the viewer shows the call log, so without one it has nothing to show, and
83
+ * it says so rather than silently doing nothing.
84
+ */
85
+ readViewer(loggingEnabled, warnings) {
86
+ const history = this.readCount(this.env.DB_LOG_HISTORY, EnvironmentConfigLoader.DEFAULT_VIEWER_HISTORY);
87
+ const raw = this.env.DB_LOG_PORT;
88
+ if (!raw) {
89
+ return { viewerPort: null, viewerHistory: history };
90
+ }
91
+ const port = Number(raw);
92
+ if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
93
+ warnings.push(`DB_LOG_PORT="${raw}" is not a port from 1 to 65535, so the live viewer is off.`);
94
+ return { viewerPort: null, viewerHistory: history };
95
+ }
96
+ if (!loggingEnabled) {
97
+ warnings.push("DB_LOG_PORT is set but call logging is off. Set DB_LOG_DIR, DB_LOG=true or DB_LOG_FILE to use the live viewer.");
98
+ return { viewerPort: null, viewerHistory: history };
99
+ }
100
+ return { viewerPort: port, viewerHistory: history };
101
+ }
102
+ /** A non-negative integer, or the fallback. 0 is allowed and means none. */
103
+ readCount(value, fallback) {
104
+ if (value === undefined || value === "") {
105
+ return fallback;
106
+ }
107
+ const parsed = Number(value);
108
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
109
+ }
52
110
  /**
53
111
  * DB_PROFILES: `{"name": "url"}` or `{"name": {"url": "...", "password": "..."}}`.
54
112
  *
@@ -7,11 +7,16 @@ import { UnsupportedOperationError } from "../errors/UnsupportedOperationError.j
7
7
  * The two optional capabilities, indexes and foreign keys, default to a
8
8
  * refusal here, so an engine without them needs no code to say so, and an
9
9
  * engine that has them overrides the one method.
10
+ *
11
+ * Every statement a driver sends goes through `traced`, which reports it to
12
+ * the call log when logging is on and is a plain call when it is off.
10
13
  */
11
14
  export class BaseDriver {
12
15
  target;
13
- constructor(target) {
16
+ tracer;
17
+ constructor(target, tracer) {
14
18
  this.target = target;
19
+ this.tracer = tracer;
15
20
  }
16
21
  async listIndexes(_name) {
17
22
  throw new UnsupportedOperationError(this.label, "indexes to list");
@@ -19,6 +24,10 @@ export class BaseDriver {
19
24
  async listForeignKeys(_name) {
20
25
  throw new UnsupportedOperationError(this.label, "foreign keys");
21
26
  }
27
+ /** Run one statement through the tracer, labelled with this engine. */
28
+ traced(text, params, run, describe) {
29
+ return this.tracer.trace(this.label, { text, params }, run, describe);
30
+ }
22
31
  get label() {
23
32
  return EngineCatalog.label(this.target.engine);
24
33
  }
@@ -25,8 +25,8 @@ export class MongoDriver extends BaseDriver {
25
25
  static SYSTEM_DATABASES = new Set(["admin", "local", "config"]);
26
26
  static SCHEMA_SAMPLE_SIZE = 100;
27
27
  client;
28
- constructor(target, tuning, stages = new MongoStageAllowlist(), sampler = new MongoSchemaSampler()) {
29
- super(target);
28
+ constructor(target, tuning, tracer, stages = new MongoStageAllowlist(), sampler = new MongoSchemaSampler()) {
29
+ super(target, tracer);
30
30
  this.tuning = tuning;
31
31
  this.stages = stages;
32
32
  this.sampler = sampler;
@@ -34,7 +34,8 @@ export class MongoDriver extends BaseDriver {
34
34
  }
35
35
  async verify() {
36
36
  const { client } = await this.client.get();
37
- await client.db(this.target.database || "admin").command({ ping: 1 });
37
+ const database = this.target.database || "admin";
38
+ await this.traced(`${database}.ping`, undefined, () => client.db(database).command({ ping: 1 }), () => "ok");
38
39
  }
39
40
  close() {
40
41
  return this.client.close();
@@ -45,7 +46,7 @@ export class MongoDriver extends BaseDriver {
45
46
  */
46
47
  async listDatabases() {
47
48
  const { client } = await this.client.get();
48
- const result = await client.db("admin").admin().listDatabases({ nameOnly: true, authorizedDatabases: true });
49
+ const result = await this.traced("admin.listDatabases", { nameOnly: true, authorizedDatabases: true }, () => client.db("admin").admin().listDatabases({ nameOnly: true, authorizedDatabases: true }), (answer) => `${answer.databases.length} databases`);
49
50
  return result.databases.map((entry) => ({
50
51
  name: entry.name,
51
52
  system: MongoDriver.SYSTEM_DATABASES.has(entry.name),
@@ -53,22 +54,18 @@ export class MongoDriver extends BaseDriver {
53
54
  }
54
55
  async listObjects(pattern, limit) {
55
56
  const database = await this.database();
56
- const collections = await database
57
- .listCollections({}, { nameOnly: true, authorizedCollections: true })
58
- .toArray();
57
+ const collections = await this.traced(`${database.databaseName}.listCollections`, undefined, () => database.listCollections({}, { nameOnly: true, authorizedCollections: true }).toArray());
59
58
  return new GlobPattern(pattern).apply(collections.map((entry) => entry.name).sort(), limit);
60
59
  }
61
60
  /** The inferred shape of a sample, plus any JSON Schema validator the collection declares. */
62
61
  async describeObject(name) {
63
62
  const database = await this.database();
64
- const [info] = await database.listCollections({ name }).toArray();
63
+ const [info] = await this.traced(`${database.databaseName}.listCollections`, { name }, () => database.listCollections({ name }).toArray());
65
64
  if (!info) {
66
65
  throw new ObjectNotFoundError(this.objectNoun, name);
67
66
  }
68
- const documents = await database
69
- .collection(name)
70
- .aggregate([{ $sample: { size: MongoDriver.SCHEMA_SAMPLE_SIZE } }], { maxTimeMS: this.tuning.queryTimeoutMs })
71
- .toArray();
67
+ const sampling = [{ $sample: { size: MongoDriver.SCHEMA_SAMPLE_SIZE } }];
68
+ const documents = await this.traced(`${database.databaseName}.${name}.aggregate`, sampling, () => database.collection(name).aggregate(sampling, { maxTimeMS: this.tuning.queryTimeoutMs }).toArray());
72
69
  return {
73
70
  collection: name,
74
71
  sampled_documents: documents.length,
@@ -79,7 +76,7 @@ export class MongoDriver extends BaseDriver {
79
76
  async listIndexes(name) {
80
77
  await this.assertCollection(name);
81
78
  const database = await this.database();
82
- return database.collection(name).indexes();
79
+ return this.traced(`${database.databaseName}.${name}.indexes`, undefined, () => database.collection(name).indexes());
83
80
  }
84
81
  async sample(name, limit) {
85
82
  await this.assertCollection(name);
@@ -95,7 +92,8 @@ export class MongoDriver extends BaseDriver {
95
92
  skip: request.skip,
96
93
  maxTimeMS: this.tuning.queryTimeoutMs,
97
94
  });
98
- return this.toJson(bson, await cursor.toArray());
95
+ const documents = await this.traced(`${database.databaseName}.${collection}.find`, request, () => cursor.toArray());
96
+ return this.toJson(bson, documents);
99
97
  }
100
98
  /**
101
99
  * Reads at most `limit + 1` documents and closes the cursor, so a pipeline
@@ -112,32 +110,31 @@ export class MongoDriver extends BaseDriver {
112
110
  batchSize: limit + 1,
113
111
  });
114
112
  const rows = [];
115
- try {
116
- for await (const document of cursor) {
117
- rows.push(document);
118
- if (rows.length > limit) {
119
- break;
113
+ await this.traced(`${database.databaseName}.${collection}.aggregate`, pipeline, async () => {
114
+ try {
115
+ for await (const document of cursor) {
116
+ rows.push(document);
117
+ if (rows.length > limit) {
118
+ break;
119
+ }
120
120
  }
121
121
  }
122
- }
123
- finally {
124
- await cursor.close();
125
- }
122
+ finally {
123
+ await cursor.close();
124
+ }
125
+ return rows;
126
+ });
126
127
  return { rows: this.toJson(bson, rows.slice(0, limit)), truncated: rows.length > limit };
127
128
  }
128
129
  async count(collection, filter) {
129
130
  const { bson } = await this.client.get();
130
131
  const database = await this.database();
131
- return database
132
- .collection(collection)
133
- .countDocuments(this.fromJson(bson, filter), { maxTimeMS: this.tuning.queryTimeoutMs });
132
+ return this.traced(`${database.databaseName}.${collection}.countDocuments`, filter, () => database.collection(collection).countDocuments(this.fromJson(bson, filter), { maxTimeMS: this.tuning.queryTimeoutMs }));
134
133
  }
135
134
  async distinct(collection, field, filter) {
136
135
  const { bson } = await this.client.get();
137
136
  const database = await this.database();
138
- const values = await database
139
- .collection(collection)
140
- .distinct(field, this.fromJson(bson, filter), { maxTimeMS: this.tuning.queryTimeoutMs });
137
+ const values = await this.traced(`${database.databaseName}.${collection}.distinct`, { field, filter }, () => database.collection(collection).distinct(field, this.fromJson(bson, filter), { maxTimeMS: this.tuning.queryTimeoutMs }));
141
138
  return this.toJson(bson, values);
142
139
  }
143
140
  async database() {
@@ -146,7 +143,7 @@ export class MongoDriver extends BaseDriver {
146
143
  }
147
144
  async assertCollection(name) {
148
145
  const database = await this.database();
149
- const found = await database.listCollections({ name }, { nameOnly: true }).toArray();
146
+ const found = await this.traced(`${database.databaseName}.listCollections`, { name }, () => database.listCollections({ name }, { nameOnly: true }).toArray());
150
147
  if (found.length === 0) {
151
148
  throw new ObjectNotFoundError(this.objectNoun, name);
152
149
  }