@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
@@ -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
+ }
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { JsonSerializer } from "../../formatting/JsonSerializer.js";
4
+ import { RecordJson } from "../RecordJson.js";
5
+ import { LogFileNames } from "./LogFileNames.js";
6
+ /**
7
+ * Saves every entry as its own JSON file in the permanent log folder.
8
+ *
9
+ * Each file is written under a temporary name and renamed into place, which
10
+ * is atomic on one filesystem, so a reader (this process's viewer, another
11
+ * copy's, or a person with `ls`) never sees a half-written file. Temporaries
12
+ * end in `.tmp` and are ignored by LogFileNames.
13
+ *
14
+ * The folder is created readable by the owner only, and each file likewise:
15
+ * with logging on they hold every query and every result.
16
+ *
17
+ * Synchronous for the same reason as FileSink: shutdown ends with
18
+ * `process.exit`, which would lose buffered writes.
19
+ *
20
+ * Nothing is ever deleted or rotated here. The folder is permanent by design.
21
+ */
22
+ export class FolderLogChannel {
23
+ directory;
24
+ onWritten;
25
+ serializer;
26
+ sequence = 0;
27
+ /**
28
+ * @param onWritten told of each entry just saved, so this process's own
29
+ * viewer can show it at once instead of waiting for its next scan.
30
+ */
31
+ constructor(directory, onWritten = () => undefined, serializer = new JsonSerializer()) {
32
+ this.directory = directory;
33
+ this.onWritten = onWritten;
34
+ this.serializer = serializer;
35
+ }
36
+ get description() {
37
+ return this.directory;
38
+ }
39
+ onCall(call) {
40
+ const name = LogFileNames.build(call.at, call.pid, "call", ++this.sequence, call.tool, call.failed);
41
+ this.save(name, "call", RecordJson.call(call));
42
+ }
43
+ onStatement(statement) {
44
+ const name = LogFileNames.build(statement.at, statement.pid, "statement", ++this.sequence, null, statement.failed);
45
+ this.save(name, "statement", RecordJson.statement(statement));
46
+ }
47
+ save(relativePath, kind, data) {
48
+ const target = join(this.directory, relativePath);
49
+ const day = join(this.directory, relativePath.split("/")[0]);
50
+ mkdirSync(day, { recursive: true, mode: 0o700 });
51
+ const temporary = `${target}.${process.pid}.tmp`;
52
+ writeFileSync(temporary, `${this.serializer.stringify(data)}\n`, { mode: 0o600 });
53
+ renameSync(temporary, target);
54
+ this.onWritten({ key: relativePath, kind, data });
55
+ }
56
+ }
@@ -0,0 +1,214 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { LogFileNames } from "./LogFileNames.js";
5
+ import { Paging } from "./LogStore.js";
6
+ /**
7
+ * Reads the permanent log folder for the viewer: every entry saved by every
8
+ * copy of the server that shares it, newest first.
9
+ *
10
+ * **An index of file names, not contents.** On first use the folder is
11
+ * listed once, and each name parsed (LogFileNames puts time, pid, tool and
12
+ * outcome in it), so paging, the tool filter and "failed only" never open a
13
+ * file. Only the entries on the page being shown are read, plus, for a text
14
+ * search, the candidates it has to look inside.
15
+ *
16
+ * **New entries.** This process's own writes arrive at once through
17
+ * `noteWritten`. Other copies' writes are found by rescanning today's folder
18
+ * once a second: one small directory listing, dependable on every platform,
19
+ * where `fs.watch` is not (it drops and duplicates events, and behaves
20
+ * differently on network and bind-mounted folders). The timer is unref'd, so
21
+ * it never keeps the process alive.
22
+ *
23
+ * Files deleted by hand disappear from the index the next time they would
24
+ * have been read.
25
+ */
26
+ export class FolderLogStore {
27
+ directory;
28
+ static SCAN_INTERVAL_MS = 1000;
29
+ index = null;
30
+ known = new Set();
31
+ /** Entries already pushed to the viewer, so the scan and noteWritten never push one twice. */
32
+ emitted = new Set();
33
+ listeners = new Set();
34
+ timer = null;
35
+ constructor(directory) {
36
+ this.directory = directory;
37
+ }
38
+ start() {
39
+ if (this.timer) {
40
+ return;
41
+ }
42
+ this.load();
43
+ this.timer = setInterval(() => void this.scanRecent(), FolderLogStore.SCAN_INTERVAL_MS);
44
+ this.timer.unref();
45
+ }
46
+ stop() {
47
+ if (this.timer) {
48
+ clearInterval(this.timer);
49
+ this.timer = null;
50
+ }
51
+ }
52
+ subscribe(listener) {
53
+ this.listeners.add(listener);
54
+ return () => this.listeners.delete(listener);
55
+ }
56
+ /** An entry this process has just saved: index it and tell the viewer now. */
57
+ noteWritten(entry) {
58
+ const [day, file] = entry.key.split("/");
59
+ const info = LogFileNames.parse(day, file);
60
+ if (!info) {
61
+ return;
62
+ }
63
+ // Loaded first, so a write before the first query cannot leave the index
64
+ // holding only this one entry and the rest of the folder unscanned.
65
+ this.load();
66
+ this.add(info);
67
+ this.emit(entry);
68
+ }
69
+ async tools() {
70
+ const names = new Set();
71
+ for (const info of this.load()) {
72
+ if (info.tool) {
73
+ names.add(info.tool);
74
+ }
75
+ }
76
+ return Array.from(names).sort();
77
+ }
78
+ async query(query) {
79
+ let candidates = this.load().filter((info) => (!query.tool || info.tool === query.tool) && (!query.failedOnly || info.failed));
80
+ const needle = query.text?.trim().toLowerCase();
81
+ if (needle) {
82
+ const matching = [];
83
+ for (const info of candidates) {
84
+ const text = await this.readText(info);
85
+ if (text !== null && text.toLowerCase().includes(needle)) {
86
+ matching.push(info);
87
+ }
88
+ }
89
+ candidates = matching;
90
+ }
91
+ const paged = Paging.page(candidates, query);
92
+ const entries = [];
93
+ for (const info of paged.slice) {
94
+ const entry = await this.read(info);
95
+ if (entry) {
96
+ entries.push(entry);
97
+ }
98
+ }
99
+ return { entries, total: paged.total, page: paged.page, size: paged.size, pages: paged.pages };
100
+ }
101
+ /** The index, built on first use: every day folder, every file, newest first. */
102
+ load() {
103
+ if (this.index) {
104
+ return this.index;
105
+ }
106
+ // Collected, then sorted once: inserting one at a time would be
107
+ // quadratic in a folder that has been filling for months.
108
+ const all = [];
109
+ for (const day of this.listDays()) {
110
+ for (const info of this.listDay(day)) {
111
+ if (!this.known.has(info.relativePath)) {
112
+ this.known.add(info.relativePath);
113
+ all.push(info);
114
+ }
115
+ }
116
+ }
117
+ this.index = all.sort((a, b) => (this.newer(a, b) ? -1 : 1));
118
+ return this.index;
119
+ }
120
+ /**
121
+ * Today's folder, and yesterday's around midnight UTC, since a copy may
122
+ * still be finishing a call dated the day before.
123
+ */
124
+ async scanRecent() {
125
+ const now = new Date();
126
+ const days = new Set([LogFileNames.dayFolder(now), LogFileNames.dayFolder(new Date(now.getTime() - 60000))]);
127
+ for (const day of days) {
128
+ for (const info of this.listDay(day)) {
129
+ if (this.add(info)) {
130
+ const entry = await this.read(info);
131
+ if (entry) {
132
+ this.emit(entry);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ /** @returns true when the entry was new. Keeps the index newest first. */
139
+ add(info) {
140
+ if (this.known.has(info.relativePath)) {
141
+ return false;
142
+ }
143
+ this.known.add(info.relativePath);
144
+ const index = this.load();
145
+ let at = 0;
146
+ while (at < index.length && this.newer(index[at], info)) {
147
+ at += 1;
148
+ }
149
+ index.splice(at, 0, info);
150
+ return true;
151
+ }
152
+ /** Newest first by time; the name breaks ties, so the order is total and stable. */
153
+ newer(a, b) {
154
+ const difference = a.at.getTime() - b.at.getTime();
155
+ return difference !== 0 ? difference > 0 : a.relativePath > b.relativePath;
156
+ }
157
+ listDays() {
158
+ if (!existsSync(this.directory)) {
159
+ return [];
160
+ }
161
+ return readdirSync(this.directory).filter((name) => LogFileNames.isDayFolder(name));
162
+ }
163
+ listDay(day) {
164
+ const folder = join(this.directory, day);
165
+ if (!existsSync(folder)) {
166
+ return [];
167
+ }
168
+ const found = [];
169
+ for (const file of readdirSync(folder)) {
170
+ const info = LogFileNames.parse(day, file);
171
+ if (info) {
172
+ found.push(info);
173
+ }
174
+ }
175
+ return found;
176
+ }
177
+ async read(info) {
178
+ const text = await this.readText(info);
179
+ if (text === null) {
180
+ return null;
181
+ }
182
+ try {
183
+ return { key: info.relativePath, kind: info.kind, data: JSON.parse(text) };
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ /** A file that has gone, deleted by hand, is dropped from the index. */
190
+ async readText(info) {
191
+ try {
192
+ return await readFile(join(this.directory, info.relativePath), "utf8");
193
+ }
194
+ catch {
195
+ this.forget(info);
196
+ return null;
197
+ }
198
+ }
199
+ forget(info) {
200
+ this.known.delete(info.relativePath);
201
+ if (this.index) {
202
+ this.index = this.index.filter((entry) => entry.relativePath !== info.relativePath);
203
+ }
204
+ }
205
+ emit(entry) {
206
+ if (this.emitted.has(entry.key)) {
207
+ return;
208
+ }
209
+ this.emitted.add(entry.key);
210
+ for (const listener of this.listeners) {
211
+ listener(entry);
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Names log files so that everything the viewer lists and filters on is in
3
+ * the name itself.
4
+ *
5
+ * `YYYY-MM-DD/HHMMSS-mmmZ_p<pid>_<c|s><sequence>_<tool>_<ok|failed>.json`
6
+ *
7
+ * - **A folder per UTC day**, so no single folder grows to tens of thousands
8
+ * of entries, and old days can be archived or deleted as a whole.
9
+ * - **Time first**, so names sort chronologically; the `Z` says it is UTC.
10
+ * - **The pid and a per-process sequence**, so several copies of the server
11
+ * writing to one folder can never produce the same name.
12
+ * - **The tool and outcome last**, so paging, the tool filter and "failed
13
+ * only" read directory listings and never open a file. Only a text search
14
+ * reads contents.
15
+ */
16
+ export class LogFileNames {
17
+ static DAY = /^\d{4}-\d{2}-\d{2}$/;
18
+ static FILE = /^(\d{2})(\d{2})(\d{2})-(\d{3})Z_p(\d+)_([cs])(\d+)_([A-Za-z0-9_-]+?)_(ok|failed)\.json$/;
19
+ static dayFolder(at) {
20
+ return at.toISOString().slice(0, 10);
21
+ }
22
+ static build(at, pid, kind, sequence, tool, failed) {
23
+ const iso = at.toISOString();
24
+ const time = `${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}-${iso.slice(20, 23)}Z`;
25
+ const marker = kind === "call" ? "c" : "s";
26
+ const label = kind === "call" ? LogFileNames.safe(tool ?? "call") : "statement";
27
+ return `${LogFileNames.dayFolder(at)}/${time}_p${pid}_${marker}${String(sequence).padStart(6, "0")}_${label}_${failed ? "failed" : "ok"}.json`;
28
+ }
29
+ static isDayFolder(name) {
30
+ return LogFileNames.DAY.test(name);
31
+ }
32
+ /** @returns null for anything that is not one of our files, including half-written temporaries. */
33
+ static parse(day, file) {
34
+ const match = LogFileNames.FILE.exec(file);
35
+ if (!match || !LogFileNames.isDayFolder(day)) {
36
+ return null;
37
+ }
38
+ const [, hh, mm, ss, ms, pid, marker, , label, outcome] = match;
39
+ const at = new Date(`${day}T${hh}:${mm}:${ss}.${ms}Z`);
40
+ if (Number.isNaN(at.getTime())) {
41
+ return null;
42
+ }
43
+ const kind = marker === "c" ? "call" : "statement";
44
+ return {
45
+ relativePath: `${day}/${file}`,
46
+ at,
47
+ pid: Number(pid),
48
+ kind,
49
+ tool: kind === "call" ? label : null,
50
+ failed: outcome === "failed",
51
+ };
52
+ }
53
+ /** Tool names are already safe; this keeps any future one from reaching the path as-is. */
54
+ static safe(name) {
55
+ return name.replace(/[^A-Za-z0-9_-]/g, "-") || "call";
56
+ }
57
+ }
@@ -0,0 +1,18 @@
1
+ /** Pages and page counts, shared by both stores so they cannot disagree. */
2
+ export class Paging {
3
+ static DEFAULT_SIZE = 20;
4
+ static MAX_SIZE = 100;
5
+ static normalise(query) {
6
+ const size = Math.min(Math.max(Math.trunc(query.size) || Paging.DEFAULT_SIZE, 1), Paging.MAX_SIZE);
7
+ const page = Math.max(Math.trunc(query.page) || 1, 1);
8
+ return { page, size };
9
+ }
10
+ static page(matching, query) {
11
+ const { page, size } = Paging.normalise(query);
12
+ const total = matching.length;
13
+ const pages = Math.max(Math.ceil(total / size), 1);
14
+ const current = Math.min(page, pages);
15
+ const start = (current - 1) * size;
16
+ return { slice: matching.slice(start, start + size), total, page: current, size, pages };
17
+ }
18
+ }
@@ -0,0 +1,70 @@
1
+ import { RecordJson } from "../RecordJson.js";
2
+ import { Paging } from "./LogStore.js";
3
+ /**
4
+ * The viewer's store when no log folder is configured: this process's most
5
+ * recent entries, in memory only, oldest dropped first past the capacity.
6
+ *
7
+ * A LogChannel as well, since without a folder there is nothing else to read
8
+ * entries back from: it is fed directly by the call logger.
9
+ */
10
+ export class MemoryLogStore {
11
+ capacity;
12
+ description = "the live viewer's memory";
13
+ entries = [];
14
+ listeners = new Set();
15
+ constructor(capacity) {
16
+ this.capacity = capacity;
17
+ }
18
+ onCall(call) {
19
+ this.add({ key: `m:${call.pid}:c${call.id}`, kind: "call", data: RecordJson.call(call) });
20
+ }
21
+ onStatement(statement) {
22
+ this.add({ key: `m:${statement.pid}:s${statement.at.getTime()}:${this.entries.length}`, kind: "statement", data: RecordJson.statement(statement) });
23
+ }
24
+ start() {
25
+ return;
26
+ }
27
+ stop() {
28
+ return;
29
+ }
30
+ subscribe(listener) {
31
+ this.listeners.add(listener);
32
+ return () => this.listeners.delete(listener);
33
+ }
34
+ async tools() {
35
+ const names = new Set();
36
+ for (const entry of this.entries) {
37
+ if (entry.kind === "call") {
38
+ names.add(String(entry.data.tool));
39
+ }
40
+ }
41
+ return Array.from(names).sort();
42
+ }
43
+ async query(query) {
44
+ const needle = query.text?.trim().toLowerCase();
45
+ const matching = this.entries
46
+ .filter((entry) => (!query.tool || (entry.kind === "call" && entry.data.tool === query.tool)) &&
47
+ (!query.failedOnly || entry.data.failed === true) &&
48
+ (!needle || entry.search.includes(needle)))
49
+ .reverse();
50
+ const paged = Paging.page(matching, query);
51
+ return {
52
+ entries: paged.slice.map(({ key, kind, data }) => ({ key, kind, data })),
53
+ total: paged.total,
54
+ page: paged.page,
55
+ size: paged.size,
56
+ pages: paged.pages,
57
+ };
58
+ }
59
+ add(entry) {
60
+ if (this.capacity > 0) {
61
+ this.entries.push({ ...entry, search: JSON.stringify(entry.data).toLowerCase() });
62
+ if (this.entries.length > this.capacity) {
63
+ this.entries.splice(0, this.entries.length - this.capacity);
64
+ }
65
+ }
66
+ for (const listener of this.listeners) {
67
+ listener(entry);
68
+ }
69
+ }
70
+ }