@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.
- package/CHANGELOG.md +13 -1
- package/README.dockerhub.md +79 -2
- package/README.md +78 -1
- package/dist/ApplicationFactory.js +111 -14
- package/dist/config/EnvironmentConfigLoader.js +58 -0
- package/dist/drivers/BaseDriver.js +10 -1
- package/dist/drivers/document/MongoDriver.js +27 -30
- package/dist/drivers/keyvalue/RedisDriver.js +20 -5
- package/dist/drivers/search/ElasticsearchDriver.js +13 -9
- package/dist/drivers/sql/ClickHouseDriver.js +12 -13
- package/dist/drivers/sql/MsSqlDriver.js +6 -6
- package/dist/drivers/sql/MySqlDriver.js +7 -5
- package/dist/drivers/sql/MySqlSessionInitializer.js +17 -2
- package/dist/drivers/sql/PostgresDriver.js +6 -6
- package/dist/drivers/sql/SqliteDriver.js +3 -3
- package/dist/formatting/JsonSerializer.js +3 -2
- package/dist/logging/CallLogger.js +139 -0
- package/dist/logging/LogChannel.js +18 -0
- package/dist/logging/LogFormatter.js +80 -0
- package/dist/logging/LogRecords.js +7 -0
- package/dist/logging/LogSink.js +38 -0
- package/dist/logging/RecordJson.js +39 -0
- package/dist/logging/Redactor.js +95 -0
- package/dist/logging/StatementTracer.js +9 -0
- package/dist/logging/ToolCallObserver.js +6 -0
- package/dist/logging/store/FolderLogChannel.js +56 -0
- package/dist/logging/store/FolderLogStore.js +214 -0
- package/dist/logging/store/LogFileNames.js +57 -0
- package/dist/logging/store/LogStore.js +18 -0
- package/dist/logging/store/MemoryLogStore.js +70 -0
- package/dist/logging/viewer/LiveLogViewer.js +256 -0
- package/dist/logging/viewer/LiveViewerObserver.js +62 -0
- package/dist/logging/viewer/ViewerAssets.js +625 -0
- package/dist/server/BackgroundService.js +1 -0
- package/dist/server/McpDbServer.js +16 -2
- package/dist/tools/BaseTool.js +8 -2
- package/dist/tools/connection/CurrentConnectionTool.js +11 -2
- 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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
81
|
-
query
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
137
|
-
|
|
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
|
|
105
|
-
return (
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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,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
|
+
}
|