@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
@@ -25,8 +25,8 @@ export class RedisDriver extends BaseDriver {
25
25
  /** Long strings are cut in samples; the length is reported so nothing is hidden silently. */
26
26
  static MAX_STRING_PREVIEW = 4096;
27
27
  client;
28
- constructor(target, tuning, logger, guard = new RedisCommandFlagsGuard()) {
29
- super(target);
28
+ constructor(target, tuning, tracer, logger, guard = new RedisCommandFlagsGuard()) {
29
+ super(target, tracer);
30
30
  this.tuning = tuning;
31
31
  this.logger = logger;
32
32
  this.guard = guard;
@@ -111,9 +111,12 @@ export class RedisDriver extends BaseDriver {
111
111
  return { key: name, type, note: "No sample reader for this type; use redis_command." };
112
112
  }
113
113
  }
114
+ /**
115
+ * The guard's COMMAND INFO lookups go through `call` too, so the log shows
116
+ * the server being asked before the command itself is sent.
117
+ */
114
118
  async command(name, args) {
115
- const client = await this.client.get();
116
- const call = (command, ...rest) => client.call(command, ...rest);
119
+ const call = (command, ...rest) => this.call(command, ...rest);
117
120
  const subcommand = this.isContainer(name) ? args[0]?.toUpperCase() : undefined;
118
121
  await this.guard.assertReadOnly(call, name, subcommand);
119
122
  return call(name, ...args);
@@ -172,9 +175,21 @@ export class RedisDriver extends BaseDriver {
172
175
  isContainer(name) {
173
176
  return ["OBJECT", "MEMORY", "XINFO"].includes(name);
174
177
  }
178
+ /** Every command this driver sends passes through here, so every one is traced. */
175
179
  async call(command, ...args) {
176
180
  const client = await this.client.get();
177
- return client.call(command, ...args);
181
+ return this.traced([command, ...args].join(" "), undefined, () => client.call(command, ...args), RedisDriver.describeReply);
182
+ }
183
+ /** Arrays by length, short scalars by value, so `GET` and `DBSIZE` read naturally in the log. */
184
+ static describeReply(reply) {
185
+ if (Array.isArray(reply)) {
186
+ return `${reply.length} item${reply.length === 1 ? "" : "s"}`;
187
+ }
188
+ if (reply === null) {
189
+ return "nil";
190
+ }
191
+ const text = String(reply);
192
+ return text.length <= 40 ? text : `${text.length} characters`;
178
193
  }
179
194
  databaseIndex() {
180
195
  const parsed = Number.parseInt(this.target.database, 10);
@@ -18,10 +18,10 @@ export class ElasticsearchDriver extends BaseDriver {
18
18
  tuning;
19
19
  fetcher;
20
20
  family = "search";
21
- constructor(target, tuning,
21
+ constructor(target, tuning, tracer,
22
22
  // Wrapped so fetch is never invoked with this driver as its receiver.
23
23
  fetcher = (input, init) => fetch(input, init)) {
24
- super(target);
24
+ super(target, tracer);
25
25
  this.tuning = tuning;
26
26
  this.fetcher = fetcher;
27
27
  }
@@ -80,13 +80,17 @@ export class ElasticsearchDriver extends BaseDriver {
80
80
  }
81
81
  async send(request, timeoutMs = this.tuning.queryTimeoutMs) {
82
82
  const { method, path, body } = this.route(request);
83
- const response = await this.fetcher(`${this.baseUrl()}${path}`, {
84
- method,
85
- headers: this.headers(),
86
- body: body === undefined ? undefined : JSON.stringify(body),
87
- signal: AbortSignal.timeout(timeoutMs),
88
- });
89
- const text = await response.text();
83
+ // Traced as the request line, with the body as its parameters. The
84
+ // authorization header is never part of what is traced.
85
+ const { response, text } = await this.traced(`${method} ${path}`, body, async () => {
86
+ const answer = await this.fetcher(`${this.baseUrl()}${path}`, {
87
+ method,
88
+ headers: this.headers(),
89
+ body: body === undefined ? undefined : JSON.stringify(body),
90
+ signal: AbortSignal.timeout(timeoutMs),
91
+ });
92
+ return { response: answer, text: await answer.text() };
93
+ }, (result) => `HTTP ${result.response.status}`);
90
94
  const parsed = text ? this.parse(text) : null;
91
95
  if (response.status === 404 && request.kind !== "cluster" && request.kind !== "indices") {
92
96
  throw new ObjectNotFoundError(this.objectNoun, request.index);
@@ -29,8 +29,8 @@ export class ClickHouseDriver extends BaseDriver {
29
29
  */
30
30
  static MAX_RESULT_ROWS = 10000;
31
31
  client;
32
- constructor(target, tuning) {
33
- super(target);
32
+ constructor(target, tuning, tracer) {
33
+ super(target, tracer);
34
34
  this.tuning = tuning;
35
35
  this.client = new LazyResource(() => this.open(), (opened) => opened.client.close());
36
36
  }
@@ -77,13 +77,15 @@ export class ClickHouseDriver extends BaseDriver {
77
77
  }
78
78
  async run(sql, params) {
79
79
  const { client, settings } = await this.client.get();
80
- const result = await client.query({
81
- query: sql,
82
- format: "JSONEachRow",
83
- query_params: params,
84
- clickhouse_settings: settings,
80
+ return this.traced(sql, params, async () => {
81
+ const result = await client.query({
82
+ query: sql,
83
+ format: "JSONEachRow",
84
+ query_params: params,
85
+ clickhouse_settings: settings,
86
+ });
87
+ return (await result.json());
85
88
  });
86
- return (await result.json());
87
89
  }
88
90
  async assertExists(database, table) {
89
91
  const rows = await this.run("SELECT 1 AS found FROM system.tables WHERE database = {database:String} AND name = {table:String}", { database, table });
@@ -133,11 +135,8 @@ export class ClickHouseDriver extends BaseDriver {
133
135
  * attaching any is exactly what a read-only account refuses.
134
136
  */
135
137
  async negotiateSettings(client) {
136
- const result = await client.query({
137
- query: "SELECT toUInt8(getSetting('readonly')) AS readonly",
138
- format: "JSONEachRow",
139
- });
140
- const rows = (await result.json());
138
+ const probe = "SELECT toUInt8(getSetting('readonly')) AS readonly";
139
+ const rows = (await this.traced(probe, undefined, async () => (await client.query({ query: probe, format: "JSONEachRow" })).json()));
141
140
  const level = Number(rows[0]?.readonly ?? 0);
142
141
  const limits = {
143
142
  max_execution_time: Math.max(1, Math.ceil(this.tuning.queryTimeoutMs / 1000)),
@@ -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") {
@@ -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
+ }