@shibbirweb/mcp-db-read-only 0.1.0 → 1.0.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 (40) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.dockerhub.md +233 -234
  3. package/README.md +221 -269
  4. package/dist/ApplicationFactory.js +111 -14
  5. package/dist/cli/ViewerCommand.js +117 -0
  6. package/dist/config/EnvironmentConfigLoader.js +58 -0
  7. package/dist/drivers/BaseDriver.js +10 -1
  8. package/dist/drivers/document/MongoDriver.js +27 -30
  9. package/dist/drivers/keyvalue/RedisDriver.js +20 -5
  10. package/dist/drivers/search/ElasticsearchDriver.js +13 -9
  11. package/dist/drivers/sql/ClickHouseDriver.js +12 -13
  12. package/dist/drivers/sql/MsSqlDriver.js +6 -6
  13. package/dist/drivers/sql/MySqlDriver.js +7 -5
  14. package/dist/drivers/sql/MySqlSessionInitializer.js +17 -2
  15. package/dist/drivers/sql/PostgresDriver.js +6 -6
  16. package/dist/drivers/sql/SqliteDriver.js +3 -3
  17. package/dist/formatting/JsonSerializer.js +3 -2
  18. package/dist/index.js +16 -7
  19. package/dist/logging/CallLogger.js +139 -0
  20. package/dist/logging/LogChannel.js +18 -0
  21. package/dist/logging/LogFormatter.js +80 -0
  22. package/dist/logging/LogRecords.js +7 -0
  23. package/dist/logging/LogSink.js +38 -0
  24. package/dist/logging/RecordJson.js +39 -0
  25. package/dist/logging/Redactor.js +95 -0
  26. package/dist/logging/StatementTracer.js +9 -0
  27. package/dist/logging/ToolCallObserver.js +6 -0
  28. package/dist/logging/store/FolderLogChannel.js +56 -0
  29. package/dist/logging/store/FolderLogStore.js +214 -0
  30. package/dist/logging/store/LogFileNames.js +57 -0
  31. package/dist/logging/store/LogStore.js +18 -0
  32. package/dist/logging/store/MemoryLogStore.js +70 -0
  33. package/dist/logging/viewer/LiveLogViewer.js +264 -0
  34. package/dist/logging/viewer/LiveViewerObserver.js +62 -0
  35. package/dist/logging/viewer/ViewerAssets.js +625 -0
  36. package/dist/server/BackgroundService.js +1 -0
  37. package/dist/server/McpDbServer.js +16 -2
  38. package/dist/tools/BaseTool.js +8 -2
  39. package/dist/tools/connection/CurrentConnectionTool.js +11 -2
  40. package/package.json +1 -1
@@ -23,8 +23,8 @@ export class MsSqlDriver extends BaseDriver {
23
23
  dialect = "mssql";
24
24
  static SYSTEM_DATABASES = new Set(["master", "tempdb", "model", "msdb"]);
25
25
  pool;
26
- constructor(target, tuning) {
27
- super(target);
26
+ constructor(target, tuning, tracer) {
27
+ super(target, tracer);
28
28
  this.tuning = tuning;
29
29
  this.pool = new LazyResource(() => this.createPool(), (opened) => opened.pool.close());
30
30
  }
@@ -95,17 +95,17 @@ export class MsSqlDriver extends BaseDriver {
95
95
  async run(text, inputs = {}) {
96
96
  const { pool, sql } = await this.pool.get();
97
97
  const transaction = new sql.Transaction(pool);
98
- await transaction.begin();
98
+ await this.traced("BEGIN TRANSACTION", undefined, () => transaction.begin(), () => "ok");
99
99
  try {
100
100
  const request = new sql.Request(transaction);
101
101
  for (const [name, value] of Object.entries(inputs)) {
102
102
  request.input(name, value);
103
103
  }
104
- const result = await request.query(text);
105
- return (result.recordset ?? []);
104
+ const params = Object.keys(inputs).length > 0 ? inputs : undefined;
105
+ return await this.traced(text, params, async () => ((await request.query(text)).recordset ?? []));
106
106
  }
107
107
  finally {
108
- await transaction.rollback().catch(() => undefined);
108
+ await this.traced("ROLLBACK", undefined, () => transaction.rollback(), () => "ok").catch(() => undefined);
109
109
  }
110
110
  }
111
111
  async assertExists(name) {
@@ -31,8 +31,8 @@ export class MySqlDriver extends BaseDriver {
31
31
  WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL`;
32
32
  static NO_SUCH_TABLE = 1146;
33
33
  pool;
34
- constructor(target, tuning, logger) {
35
- super(target);
34
+ constructor(target, tuning, tracer, logger) {
35
+ super(target, tracer);
36
36
  this.tuning = tuning;
37
37
  this.logger = logger;
38
38
  this.pool = new LazyResource(() => this.createPool(), (pool) => pool.end());
@@ -78,8 +78,10 @@ export class MySqlDriver extends BaseDriver {
78
78
  /** Placeholders for values wherever the statement allows; identifiers are quoted. */
79
79
  async run(sql, params) {
80
80
  const pool = await this.pool.get();
81
- const [rows] = await pool.query(sql, params);
82
- return rows;
81
+ return this.traced(sql, params, async () => {
82
+ const [rows] = await pool.query(sql, params);
83
+ return rows;
84
+ });
83
85
  }
84
86
  /**
85
87
  * Runs work against one table, turning MySQL's "table doesn't exist" (error
@@ -127,7 +129,7 @@ export class MySqlDriver extends BaseDriver {
127
129
  dateStrings: true,
128
130
  ssl: this.sslOptions(),
129
131
  });
130
- new MySqlSessionInitializer(this.tuning.queryTimeoutMs, this.logger).attachTo(pool);
132
+ new MySqlSessionInitializer(this.tuning.queryTimeoutMs, this.logger, this.tracer, this.label).attachTo(pool);
131
133
  return pool;
132
134
  }
133
135
  /** `?ssl=true` requires TLS; `?ssl-mode=REQUIRED` is accepted as the MySQL spelling. */
@@ -12,6 +12,8 @@
12
12
  export class MySqlSessionInitializer {
13
13
  queryTimeoutMs;
14
14
  logger;
15
+ tracer;
16
+ engineLabel;
15
17
  /**
16
18
  * Removes the two sql_mode flags that change how quotes are read, so the
17
19
  * server lexes a statement exactly as the validator did.
@@ -38,9 +40,17 @@ export class MySqlSessionInitializer {
38
40
  ].reduce((expression, mode) => `REPLACE(${expression}, ',${mode},', ',')`, "CONCAT(',', @@SESSION.sql_mode, ',')")})`;
39
41
  /** MariaDB's name for the variable; MySQL rejects it with error 1193. */
40
42
  static UNKNOWN_VARIABLE = 1193;
41
- constructor(queryTimeoutMs, logger) {
43
+ /**
44
+ * @param tracer records each setup statement in the call log, when it is
45
+ * on. They run on the pool's own schedule, so they usually appear as
46
+ * entries of their own rather than under the call that opened the
47
+ * connection.
48
+ */
49
+ constructor(queryTimeoutMs, logger, tracer, engineLabel) {
42
50
  this.queryTimeoutMs = queryTimeoutMs;
43
51
  this.logger = logger;
52
+ this.tracer = tracer;
53
+ this.engineLabel = engineLabel;
44
54
  }
45
55
  /**
46
56
  * Hooks the pool's `connection` event, which fires once per physical
@@ -70,7 +80,10 @@ export class MySqlSessionInitializer {
70
80
  * gap affects only the timeout, never the read-only mode set above.
71
81
  */
72
82
  applyTimeout(connection) {
73
- connection.query(`SET SESSION MAX_EXECUTION_TIME = ${this.queryTimeoutMs}`, (error) => {
83
+ const sql = `SET SESSION MAX_EXECUTION_TIME = ${this.queryTimeoutMs}`;
84
+ const started = Date.now();
85
+ connection.query(sql, (error) => {
86
+ this.tracer.record(this.engineLabel, { text: sql }, Date.now() - started, error ?? undefined);
74
87
  if (!error) {
75
88
  return;
76
89
  }
@@ -91,7 +104,9 @@ export class MySqlSessionInitializer {
91
104
  * not.
92
105
  */
93
106
  apply(connection, sql, description) {
107
+ const started = Date.now();
94
108
  connection.query(sql, (error) => {
109
+ this.tracer.record(this.engineLabel, { text: sql }, Date.now() - started, error ?? undefined);
95
110
  if (error) {
96
111
  this.logger(`could not ${description}: ${String(error)}`);
97
112
  }
@@ -32,8 +32,8 @@ export class PostgresDriver extends BaseDriver {
32
32
  */
33
33
  static RAW_TEXT_TYPES = new Set([1082, 1083, 1114, 1184, 1266]);
34
34
  pool;
35
- constructor(target, tuning) {
36
- super(target);
35
+ constructor(target, tuning, tracer) {
36
+ super(target, tracer);
37
37
  this.tuning = tuning;
38
38
  this.pool = new LazyResource(() => this.createPool(), (pool) => pool.end());
39
39
  }
@@ -106,10 +106,10 @@ export class PostgresDriver extends BaseDriver {
106
106
  const client = await pool.connect();
107
107
  let broken;
108
108
  try {
109
- await client.query(`BEGIN TRANSACTION READ ONLY; SET LOCAL statement_timeout = ${this.tuning.queryTimeoutMs}; SET LOCAL standard_conforming_strings = on`);
109
+ const opening = `BEGIN TRANSACTION READ ONLY; SET LOCAL statement_timeout = ${this.tuning.queryTimeoutMs}; SET LOCAL standard_conforming_strings = on`;
110
+ await this.traced(opening, undefined, () => client.query(opening), () => "ok");
110
111
  const config = { text, values, queryMode: "extended" };
111
- const result = await client.query(config);
112
- return result.rows;
112
+ return await this.traced(text, values, async () => (await client.query(config)).rows);
113
113
  }
114
114
  finally {
115
115
  broken = await this.rollback(client);
@@ -120,7 +120,7 @@ export class PostgresDriver extends BaseDriver {
120
120
  }
121
121
  async rollback(client) {
122
122
  try {
123
- await client.query("ROLLBACK");
123
+ await this.traced("ROLLBACK", undefined, () => client.query("ROLLBACK"), () => "ok");
124
124
  return undefined;
125
125
  }
126
126
  catch (error) {
@@ -134,8 +134,8 @@ export class SqliteDriver extends BaseDriver {
134
134
  family = "sql";
135
135
  dialect = "sqlite";
136
136
  worker;
137
- constructor(target, tuning) {
138
- super(target);
137
+ constructor(target, tuning, tracer) {
138
+ super(target, tracer);
139
139
  this.tuning = tuning;
140
140
  this.worker = new LazyResource(() => SqliteWorkerHandle.start({ path: target.database, busyTimeoutMs: tuning.connectTimeoutMs }, tuning.connectTimeoutMs), (handle) => handle.terminate());
141
141
  }
@@ -190,7 +190,7 @@ export class SqliteDriver extends BaseDriver {
190
190
  async run(sql, params = []) {
191
191
  const handle = await this.worker.get();
192
192
  try {
193
- return await handle.run(sql, params, this.tuning.queryTimeoutMs);
193
+ return await this.traced(sql, params.length > 0 ? params : undefined, () => handle.run(sql, params, this.tuning.queryTimeoutMs));
194
194
  }
195
195
  catch (error) {
196
196
  if (handle.dead) {
@@ -14,7 +14,8 @@
14
14
  export class JsonSerializer {
15
15
  /** Enough bytes to recognise a value; the length says how much was omitted. */
16
16
  static BINARY_PREVIEW_BYTES = 32;
17
- stringify(value) {
17
+ /** @param indent 2 for reading, 0 for one-line JSON such as a log entry. */
18
+ stringify(value, indent = 2) {
18
19
  const serializer = this;
19
20
  return JSON.stringify(value,
20
21
  // A function rather than an arrow, because the replacer receives the
@@ -22,7 +23,7 @@ export class JsonSerializer {
22
23
  // the original Buffer.
23
24
  function (key, replaced) {
24
25
  return serializer.replace(this[key], replaced);
25
- }, 2) ?? "null";
26
+ }, indent || undefined) ?? "null";
26
27
  }
27
28
  replace(original, replaced) {
28
29
  if (typeof original === "bigint") {
package/dist/index.js CHANGED
@@ -3,13 +3,22 @@ import { ApplicationFactory } from "./ApplicationFactory.js";
3
3
  /**
4
4
  * Entry point. Nothing but construction and start.
5
5
  *
6
- * There is no configuration check and no exit path here on purpose. A server
7
- * with no usable connection still starts, still answers tools/list, and
8
- * reports the problem through tool results. An MCP client cannot show a stderr
9
- * message from a process that exited during handshake; it reports "server
10
- * failed to start", which is indistinguishable from a broken image or a wrong
11
- * path. A running server that says "call connect" is diagnosable, and usually
12
- * fixable in the same conversation.
6
+ * With no arguments, which is how every MCP client runs it, this is the MCP
7
+ * server. `viewer` runs the standalone live log viewer instead; it is loaded
8
+ * only then, so it costs the MCP server's startup nothing.
9
+ *
10
+ * For the MCP server there is no configuration check and no exit path here,
11
+ * on purpose. A server with no usable connection still starts, still answers
12
+ * tools/list, and reports the problem through tool results. An MCP client
13
+ * cannot show a stderr message from a process that exited during handshake;
14
+ * it reports "server failed to start", which is indistinguishable from a
15
+ * broken image or a wrong path. A running server that says "call connect" is
16
+ * diagnosable, and usually fixable in the same conversation.
13
17
  */
18
+ const [command, ...rest] = process.argv.slice(2);
19
+ if (command === "viewer") {
20
+ const { ViewerCommand } = await import("./cli/ViewerCommand.js");
21
+ process.exit(await new ViewerCommand().run(rest));
22
+ }
14
23
  const application = new ApplicationFactory();
15
24
  await application.create().start();
@@ -0,0 +1,139 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { Redactor } from "./Redactor.js";
3
+ /**
4
+ * The call log: one entry per tool call, with every statement the drivers
5
+ * sent while serving it.
6
+ *
7
+ * It is both halves of the logging interface. BaseTool hands it each call as
8
+ * a ToolCallObserver, and drivers report each statement to it as a
9
+ * StatementTracer. The two meet through AsyncLocalStorage: a call runs inside
10
+ * its own context, so a statement reported anywhere beneath it, however many
11
+ * awaits deep, lands on the right call without any id being passed through
12
+ * the drivers. Calls handled concurrently each keep their own statements.
13
+ *
14
+ * A statement reported with no call around it (a MySQL connection's session
15
+ * setup, fired by the pool) is written as an entry of its own.
16
+ *
17
+ * Each record goes to every channel: text to stderr or a file, the live
18
+ * browser viewer, or both.
19
+ *
20
+ * Logging can never break a call. Any failure in a channel is caught; the
21
+ * first one is reported once through the diagnostic logger and that channel
22
+ * is then switched off for the rest of the process, rather than failing, say,
23
+ * on every call once a disk fills up. The other channels carry on.
24
+ */
25
+ export class CallLogger {
26
+ channels;
27
+ describeConnection;
28
+ warn;
29
+ describeClient;
30
+ redactor;
31
+ clock;
32
+ context = new AsyncLocalStorage();
33
+ nextId = 1;
34
+ disabled = new Set();
35
+ constructor(channels, describeConnection, warn,
36
+ /** The MCP client's name, known once it has completed the handshake. */
37
+ describeClient = () => null, redactor = new Redactor(), clock = () => Date.now()) {
38
+ this.channels = channels;
39
+ this.describeConnection = describeConnection;
40
+ this.warn = warn;
41
+ this.describeClient = describeClient;
42
+ this.redactor = redactor;
43
+ this.clock = clock;
44
+ }
45
+ async observe(tool, args, run) {
46
+ const id = this.nextId++;
47
+ const started = this.clock();
48
+ const connection = this.safely(() => this.describeConnection(), null);
49
+ const context = { statements: [] };
50
+ const result = await this.context.run(context, run);
51
+ const record = this.safely(() => ({
52
+ id,
53
+ tool,
54
+ pid: process.pid,
55
+ client: this.safely(() => this.describeClient(), null),
56
+ at: new Date(started),
57
+ durationMs: this.clock() - started,
58
+ connection,
59
+ input: this.redactor.redact(args),
60
+ output: result.content.map((block) => block.text).join("\n"),
61
+ failed: Boolean(result.isError),
62
+ statements: context.statements,
63
+ }), null);
64
+ if (record) {
65
+ this.publish((channel) => channel.onCall(record));
66
+ }
67
+ return result;
68
+ }
69
+ async trace(engine, statement, run, describe = CallLogger.describe) {
70
+ const started = this.clock();
71
+ try {
72
+ const result = await run();
73
+ this.add(engine, statement, started, this.safely(() => describe(result), "ok"), false);
74
+ return result;
75
+ }
76
+ catch (error) {
77
+ this.add(engine, statement, started, CallLogger.reason(error), true);
78
+ throw error;
79
+ }
80
+ }
81
+ record(engine, statement, durationMs, error) {
82
+ const started = this.clock() - durationMs;
83
+ this.add(engine, statement, started, error ? CallLogger.reason(error) : "ok", Boolean(error));
84
+ }
85
+ /** The default outcome: a row count for arrays, "ok" for everything else. */
86
+ static describe(result) {
87
+ if (Array.isArray(result)) {
88
+ return `${result.length} row${result.length === 1 ? "" : "s"}`;
89
+ }
90
+ if (typeof result === "number") {
91
+ return String(result);
92
+ }
93
+ return "ok";
94
+ }
95
+ add(engine, statement, started, outcome, failed) {
96
+ const record = {
97
+ pid: process.pid,
98
+ client: this.safely(() => this.describeClient(), null),
99
+ engine,
100
+ text: statement.text,
101
+ params: statement.params,
102
+ at: new Date(started),
103
+ durationMs: this.clock() - started,
104
+ outcome,
105
+ failed,
106
+ };
107
+ const current = this.context.getStore();
108
+ if (current) {
109
+ current.statements.push(record);
110
+ return;
111
+ }
112
+ this.publish((channel) => channel.onStatement(record));
113
+ }
114
+ publish(deliver) {
115
+ for (const channel of this.channels) {
116
+ if (this.disabled.has(channel)) {
117
+ continue;
118
+ }
119
+ try {
120
+ deliver(channel);
121
+ }
122
+ catch (error) {
123
+ this.disabled.add(channel);
124
+ this.warn(`call logging to ${channel.description} stopped after a failure: ${CallLogger.reason(error)}`);
125
+ }
126
+ }
127
+ }
128
+ safely(read, fallback) {
129
+ try {
130
+ return read();
131
+ }
132
+ catch {
133
+ return fallback;
134
+ }
135
+ }
136
+ static reason(error) {
137
+ return error instanceof Error ? error.message : String(error);
138
+ }
139
+ }
@@ -0,0 +1,18 @@
1
+ /** Formats records as text and writes them to a sink. */
2
+ export class TextLogChannel {
3
+ sink;
4
+ formatter;
5
+ constructor(sink, formatter) {
6
+ this.sink = sink;
7
+ this.formatter = formatter;
8
+ }
9
+ get description() {
10
+ return this.sink.description;
11
+ }
12
+ onCall(call) {
13
+ this.sink.write(this.formatter.formatCall(call));
14
+ }
15
+ onStatement(statement) {
16
+ this.sink.write(this.formatter.formatStatement(statement));
17
+ }
18
+ }
@@ -0,0 +1,80 @@
1
+ import { JsonSerializer } from "../formatting/JsonSerializer.js";
2
+ import { RecordJson } from "./RecordJson.js";
3
+ /**
4
+ * For reading: one boxed block per call, with the input, every statement the
5
+ * drivers sent, and the full output, each indented under its heading.
6
+ *
7
+ * ```
8
+ * ┌─ #3 run_query · ok · 38 ms · 2026-09-25T10:14:03.221Z
9
+ * │ connection dev (MySQL) mysql://root@127.0.0.1:3306/app
10
+ * │ input
11
+ * │ { "query": "SELECT COUNT(*) AS n FROM members" }
12
+ * │ statements (1)
13
+ * │ 1. MySQL · 12 ms · 1 row
14
+ * │ SELECT COUNT(*) AS n FROM members
15
+ * │ output
16
+ * │ [ { "n": 17440 } ]
17
+ * └─
18
+ * ```
19
+ */
20
+ export class PrettyLogFormatter {
21
+ serializer;
22
+ constructor(serializer = new JsonSerializer()) {
23
+ this.serializer = serializer;
24
+ }
25
+ formatCall(call) {
26
+ const lines = [];
27
+ const status = call.failed ? "FAILED" : "ok";
28
+ lines.push(`┌─ #${call.id} ${call.tool} · ${status} · ${call.durationMs} ms · ${call.at.toISOString()}`);
29
+ lines.push(`│ connection ${call.connection ?? "none"}`);
30
+ lines.push(`│ process ${call.client ?? "unknown client"}, pid ${call.pid}`);
31
+ lines.push("│ input");
32
+ lines.push(...this.indent(this.serializer.stringify(call.input), "│ "));
33
+ if (call.statements.length > 0) {
34
+ lines.push(`│ statements (${call.statements.length})`);
35
+ call.statements.forEach((statement, index) => {
36
+ lines.push(`│ ${index + 1}. ${this.summary(statement)}`);
37
+ lines.push(...this.statementBody(statement, "│ "));
38
+ });
39
+ }
40
+ lines.push(call.failed ? "│ error" : "│ output");
41
+ lines.push(...this.indent(call.output, "│ "));
42
+ lines.push("└─");
43
+ return `${lines.join("\n")}\n`;
44
+ }
45
+ formatStatement(statement) {
46
+ const lines = [`· statement outside a tool call · ${this.summary(statement)} · ${statement.at.toISOString()}`];
47
+ lines.push(...this.statementBody(statement, " "));
48
+ return `${lines.join("\n")}\n`;
49
+ }
50
+ summary(statement) {
51
+ const outcome = statement.failed ? `FAILED: ${statement.outcome}` : statement.outcome;
52
+ return `${statement.engine} · ${statement.durationMs} ms · ${outcome}`;
53
+ }
54
+ statementBody(statement, prefix) {
55
+ const lines = this.indent(statement.text, prefix);
56
+ if (statement.params !== undefined) {
57
+ lines.push(...this.indent(`params ${this.serializer.stringify(statement.params, 0)}`, prefix));
58
+ }
59
+ return lines;
60
+ }
61
+ indent(text, prefix) {
62
+ return text.split("\n").map((line) => `${prefix}${line}`);
63
+ }
64
+ }
65
+ /**
66
+ * For machines: one JSON object per line, so `grep`, `jq` and log shippers
67
+ * can read it without knowing anything about this server.
68
+ */
69
+ export class JsonLogFormatter {
70
+ serializer;
71
+ constructor(serializer = new JsonSerializer()) {
72
+ this.serializer = serializer;
73
+ }
74
+ formatCall(call) {
75
+ return `${this.serializer.stringify(RecordJson.call(call), 0)}\n`;
76
+ }
77
+ formatStatement(statement) {
78
+ return `${this.serializer.stringify(RecordJson.statement(statement), 0)}\n`;
79
+ }
80
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What the call log records, independent of how it is written out.
3
+ *
4
+ * Kept as plain data so the two formatters (pretty and JSON) render exactly
5
+ * the same facts, and a test can assert on a record without parsing text.
6
+ */
7
+ export {};
@@ -0,0 +1,38 @@
1
+ import { appendFileSync } from "node:fs";
2
+ /**
3
+ * The MCP client's own log.
4
+ *
5
+ * stderr, never stdout: stdout carries the JSON-RPC stream, and one log line
6
+ * there would corrupt the protocol. Claude Code, for one, keeps each MCP
7
+ * server's stderr in its MCP logs.
8
+ */
9
+ export class StderrSink {
10
+ description = "stderr";
11
+ write(entry) {
12
+ process.stderr.write(entry.endsWith("\n") ? entry : `${entry}\n`);
13
+ }
14
+ }
15
+ /**
16
+ * A file, appended to.
17
+ *
18
+ * Synchronous on purpose. Shutdown ends with `process.exit`, which does not
19
+ * wait for a buffered stream to drain, so an asynchronous writer loses the
20
+ * last calls of a session: usually the ones someone opened the log to find.
21
+ * The cost is a blocking write per call, which is small next to a database
22
+ * round trip.
23
+ *
24
+ * Created readable by the owner only, since with logging on it holds every
25
+ * query and every result.
26
+ */
27
+ export class FileSink {
28
+ path;
29
+ constructor(path) {
30
+ this.path = path;
31
+ }
32
+ get description() {
33
+ return this.path;
34
+ }
35
+ write(entry) {
36
+ appendFileSync(this.path, entry.endsWith("\n") ? entry : `${entry}\n`, { mode: 0o600 });
37
+ }
38
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one JSON shape of a record, used everywhere a record leaves the process
3
+ * as data: JSON log lines, the saved files, and the live viewer.
4
+ *
5
+ * One definition, so a file written today reads back in the viewer exactly
6
+ * as a live entry does, and a field added here appears in all three.
7
+ */
8
+ export class RecordJson {
9
+ static call(call) {
10
+ return {
11
+ type: "call",
12
+ id: call.id,
13
+ tool: call.tool,
14
+ at: call.at.toISOString(),
15
+ durationMs: call.durationMs,
16
+ failed: call.failed,
17
+ pid: call.pid,
18
+ client: call.client,
19
+ connection: call.connection,
20
+ input: call.input,
21
+ statements: call.statements.map((statement) => RecordJson.statementFields(statement)),
22
+ output: call.output,
23
+ };
24
+ }
25
+ static statement(statement) {
26
+ return { type: "statement", ...RecordJson.statementFields(statement), pid: statement.pid, client: statement.client };
27
+ }
28
+ static statementFields(statement) {
29
+ return {
30
+ engine: statement.engine,
31
+ at: statement.at.toISOString(),
32
+ durationMs: statement.durationMs,
33
+ failed: statement.failed,
34
+ outcome: statement.outcome,
35
+ text: statement.text,
36
+ params: statement.params,
37
+ };
38
+ }
39
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Removes credentials from tool arguments before they are logged.
3
+ *
4
+ * Always applied, with no switch to turn it off. The call log exists to show
5
+ * what an assistant asked for, and a password is never part of that answer;
6
+ * a log file is also exactly the kind of thing that ends up attached to an
7
+ * issue or pasted into a chat.
8
+ *
9
+ * Two shapes are caught:
10
+ *
11
+ * - **An argument whose name looks like a secret** (`password`, `api_key`,
12
+ * `token`...), at any depth. The value is replaced outright.
13
+ * - **A connection URL anywhere in a string**, whose password and whose
14
+ * secret-looking query parameters are masked. The URL is split the same way
15
+ * ConnectionUrlParser splits it, so what is masked is exactly what the
16
+ * parser would have used as the password.
17
+ */
18
+ export class Redactor {
19
+ static MASK = "***";
20
+ /** The same test ConnectionUrlParser uses to keep secret options out of displayed strings. */
21
+ static SECRET_NAME = /(pass|secret|token|api[_-]?key|credential)/i;
22
+ static URL_START = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
23
+ static MAX_DEPTH = 32;
24
+ redact(value, depth = 0) {
25
+ if (depth > Redactor.MAX_DEPTH) {
26
+ return "[nested too deeply to log]";
27
+ }
28
+ if (typeof value === "string") {
29
+ return this.redactUrl(value);
30
+ }
31
+ if (Array.isArray(value)) {
32
+ return value.map((item) => this.redact(item, depth + 1));
33
+ }
34
+ if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
35
+ const out = {};
36
+ for (const [key, child] of Object.entries(value)) {
37
+ out[key] = Redactor.SECRET_NAME.test(key) && child !== "" && child != null
38
+ ? Redactor.MASK
39
+ : this.redact(child, depth + 1);
40
+ }
41
+ return out;
42
+ }
43
+ return value;
44
+ }
45
+ /**
46
+ * `scheme://user:password@host/db?api_key=...` becomes
47
+ * `scheme://user:***@host/db?api_key=***`. Anything not shaped like a URL
48
+ * is returned unchanged.
49
+ */
50
+ redactUrl(text) {
51
+ const scheme = Redactor.URL_START.exec(text);
52
+ if (!scheme) {
53
+ return text;
54
+ }
55
+ const prefix = scheme[0];
56
+ const rest = text.slice(prefix.length);
57
+ const authorityEnd = this.firstIndexOf(rest, ["/", "?", "#"]);
58
+ const authority = authorityEnd === -1 ? rest : rest.slice(0, authorityEnd);
59
+ const tail = authorityEnd === -1 ? "" : rest.slice(authorityEnd);
60
+ return `${prefix}${this.maskUserInfo(authority)}${this.maskQuery(tail)}`;
61
+ }
62
+ maskUserInfo(authority) {
63
+ const at = authority.lastIndexOf("@");
64
+ if (at === -1) {
65
+ return authority;
66
+ }
67
+ const userInfo = authority.slice(0, at);
68
+ const colon = userInfo.indexOf(":");
69
+ if (colon === -1 || colon === userInfo.length - 1) {
70
+ return authority;
71
+ }
72
+ return `${userInfo.slice(0, colon + 1)}${Redactor.MASK}${authority.slice(at)}`;
73
+ }
74
+ maskQuery(tail) {
75
+ const queryAt = tail.indexOf("?");
76
+ if (queryAt === -1) {
77
+ return tail;
78
+ }
79
+ const hashAt = tail.indexOf("#", queryAt);
80
+ const query = tail.slice(queryAt + 1, hashAt === -1 ? undefined : hashAt);
81
+ const masked = query
82
+ .split("&")
83
+ .map((pair) => {
84
+ const equals = pair.indexOf("=");
85
+ const name = equals === -1 ? pair : pair.slice(0, equals);
86
+ return equals !== -1 && Redactor.SECRET_NAME.test(name) ? `${name}=${Redactor.MASK}` : pair;
87
+ })
88
+ .join("&");
89
+ return `${tail.slice(0, queryAt + 1)}${masked}${hashAt === -1 ? "" : tail.slice(hashAt)}`;
90
+ }
91
+ firstIndexOf(value, needles) {
92
+ const positions = needles.map((needle) => value.indexOf(needle)).filter((index) => index !== -1);
93
+ return positions.length === 0 ? -1 : Math.min(...positions);
94
+ }
95
+ }
@@ -0,0 +1,9 @@
1
+ /** The tracer when logging is off: no records, no timing, no overhead beyond one call. */
2
+ export class SilentTracer {
3
+ trace(_engine, _statement, run) {
4
+ return run();
5
+ }
6
+ record() {
7
+ return;
8
+ }
9
+ }
@@ -0,0 +1,6 @@
1
+ /** The observer when logging is off. */
2
+ export class SilentObserver {
3
+ observe(_tool, _args, run) {
4
+ return run();
5
+ }
6
+ }