@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,256 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { JsonSerializer } from "../../formatting/JsonSerializer.js";
4
+ import { Paging } from "../store/LogStore.js";
5
+ import { ViewerAssets } from "./ViewerAssets.js";
6
+ /**
7
+ * `lsof`, present on macOS and most Linux hosts. Absent in the Alpine image,
8
+ * where the message simply says the port is in use without naming anyone.
9
+ */
10
+ export const lsofPortOwner = (port) => new Promise((resolve) => {
11
+ execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], { timeout: 2000 }, (error, stdout) => {
12
+ if (error) {
13
+ resolve(null);
14
+ return;
15
+ }
16
+ const pid = /^p(\d+)$/m.exec(stdout)?.[1];
17
+ const command = /^c(.+)$/m.exec(stdout)?.[1];
18
+ resolve(pid ? `${command ?? "a process"} (pid ${pid})` : null);
19
+ });
20
+ });
21
+ /**
22
+ * The live log viewer: a page in the browser listing logged calls, a page at
23
+ * a time, newest first, and updating as new ones arrive.
24
+ *
25
+ * It reads a LogStore: the permanent log folder when DB_LOG_DIR is set, which
26
+ * holds the calls of every copy of the server sharing it, or this process's
27
+ * recent calls in memory otherwise. It never sees a record that has not been
28
+ * through the redactor, because the stores only ever hold what the call
29
+ * logger produced.
30
+ *
31
+ * It serves, read-only, with Node's own `http` module:
32
+ *
33
+ * - `/`, `/viewer.js`, `/viewer.css`: the page, self-contained, fetching
34
+ * nothing from anywhere else;
35
+ * - `/api/entries?page=&size=&tool=&failed=1&q=`: one page of entries, with
36
+ * the filters applied across everything stored;
37
+ * - `/api/tools`: the tool names present, for the tool filter;
38
+ * - `/events`: Server-Sent Events announcing each new entry.
39
+ *
40
+ * It binds its port **on the first tool call, not at startup**. An MCP client
41
+ * such as Claude Desktop starts one copy of the server per chat surface, and
42
+ * most of those copies never receive a call. Binding lazily means the copy
43
+ * actually in use gets the port, and idle copies never hold one.
44
+ *
45
+ * It must never become the reason the process misbehaves:
46
+ *
47
+ * - **A port that is taken is reported, not fatal.** `ensureRunning` returns
48
+ * why, naming the process that holds the port where it can, and every
49
+ * later call retries, so freeing the port brings the viewer up without a
50
+ * restart.
51
+ * - **It never keeps the process alive.** The listening socket and every
52
+ * connected browser are unref'd, so the process lives exactly as long as it
53
+ * would with no viewer. Without this, a client that died without sending
54
+ * SIGTERM would leave an orphan holding the port, and the next session's
55
+ * viewer would find it taken.
56
+ */
57
+ export class LiveLogViewer {
58
+ host;
59
+ port;
60
+ store;
61
+ logger;
62
+ portOwner;
63
+ assets;
64
+ serializer;
65
+ /** Keeps idle connections open through proxies and sleeping laptops. */
66
+ static HEARTBEAT_MS = 15000;
67
+ clients = new Set();
68
+ server = null;
69
+ heartbeat = null;
70
+ unsubscribe = null;
71
+ current = { state: "idle" };
72
+ binding = null;
73
+ stopped = false;
74
+ constructor(host, port, store, logger, portOwner = lsofPortOwner, assets = new ViewerAssets(), serializer = new JsonSerializer()) {
75
+ this.host = host;
76
+ this.port = port;
77
+ this.store = store;
78
+ this.logger = logger;
79
+ this.portOwner = portOwner;
80
+ this.assets = assets;
81
+ this.serializer = serializer;
82
+ }
83
+ get status() {
84
+ return this.current;
85
+ }
86
+ /** The port actually bound, which differs from the requested one only when that was 0. */
87
+ get boundPort() {
88
+ const address = this.server?.address();
89
+ return address && typeof address === "object" ? address.port : null;
90
+ }
91
+ /** Binding is deferred to the first tool call; see the class comment. */
92
+ async start() {
93
+ return;
94
+ }
95
+ /**
96
+ * Serve, if not already serving. Called before every tool call; cheap once
97
+ * running, and a quick retry while the port is taken. Never throws.
98
+ * Concurrent calls share one attempt.
99
+ */
100
+ ensureRunning() {
101
+ if (this.stopped || this.current.state === "running") {
102
+ return Promise.resolve(this.current);
103
+ }
104
+ if (!this.binding) {
105
+ this.binding = this.bind().finally(() => {
106
+ this.binding = null;
107
+ });
108
+ }
109
+ return this.binding;
110
+ }
111
+ /** Never throws: shutdown calls it. */
112
+ async stop() {
113
+ this.stopped = true;
114
+ if (this.heartbeat) {
115
+ clearInterval(this.heartbeat);
116
+ this.heartbeat = null;
117
+ }
118
+ this.unsubscribe?.();
119
+ this.unsubscribe = null;
120
+ this.store.stop();
121
+ for (const client of this.clients) {
122
+ client.end();
123
+ }
124
+ this.clients.clear();
125
+ const server = this.server;
126
+ this.server = null;
127
+ if (server) {
128
+ await new Promise((resolve) => server.close(() => resolve()));
129
+ }
130
+ }
131
+ async bind() {
132
+ const server = createServer((request, response) => void this.handle(request, response));
133
+ const failure = await new Promise((resolve) => {
134
+ server.once("error", (error) => resolve(error));
135
+ server.listen(this.port, this.host, () => resolve(null));
136
+ });
137
+ if (failure) {
138
+ server.close();
139
+ this.current = { state: "unavailable", port: this.port, reason: await this.describeFailure(failure) };
140
+ return this.current;
141
+ }
142
+ server.unref();
143
+ server.on("error", (error) => this.logger(`live log viewer: ${error.message}`));
144
+ this.server = server;
145
+ this.store.start();
146
+ this.unsubscribe = this.store.subscribe((entry) => this.broadcast(this.frame(entry)));
147
+ this.heartbeat = setInterval(() => this.broadcast(": heartbeat\n\n"), LiveLogViewer.HEARTBEAT_MS);
148
+ this.heartbeat.unref();
149
+ this.current = { state: "running", url: `http://${this.displayHost()}:${this.boundPort}/` };
150
+ // Said plainly, because with no access control anyone who can reach
151
+ // this address can read every query and every result.
152
+ this.logger(`live log viewer at ${this.current.url} (listening on ${this.host}, no access control: anyone who can reach this port can read the full call log)`);
153
+ return this.current;
154
+ }
155
+ async describeFailure(error) {
156
+ if (error.code !== "EADDRINUSE") {
157
+ return `port ${this.port} could not be opened: ${error.message}`;
158
+ }
159
+ const owner = await this.portOwner(this.port).catch(() => null);
160
+ return owner ? `port ${this.port} is used by ${owner}` : `port ${this.port} is already in use`;
161
+ }
162
+ broadcast(frame) {
163
+ for (const client of this.clients) {
164
+ client.write(frame);
165
+ }
166
+ }
167
+ /**
168
+ * One SSE message. The data is compact JSON, which escapes every newline
169
+ * inside its strings, so it is always a single `data:` line and cannot be
170
+ * split or spoofed by content containing blank lines.
171
+ */
172
+ frame(entry) {
173
+ return `event: entry\ndata: ${this.serializer.stringify(entry, 0)}\n\n`;
174
+ }
175
+ async handle(request, response) {
176
+ // Reads only. Nothing on this server accepts input.
177
+ if (request.method !== "GET" && request.method !== "HEAD") {
178
+ this.send(response, 405, "text/plain; charset=utf-8", "Method not allowed", { allow: "GET, HEAD" });
179
+ return;
180
+ }
181
+ const url = new URL(request.url ?? "/", "http://viewer.invalid");
182
+ try {
183
+ switch (url.pathname) {
184
+ case "/":
185
+ this.send(response, 200, "text/html; charset=utf-8", this.assets.html);
186
+ return;
187
+ case "/viewer.js":
188
+ this.send(response, 200, "text/javascript; charset=utf-8", this.assets.script);
189
+ return;
190
+ case "/viewer.css":
191
+ this.send(response, 200, "text/css; charset=utf-8", this.assets.stylesheet);
192
+ return;
193
+ case "/api/entries":
194
+ this.sendJson(response, await this.store.query(this.parseQuery(url.searchParams)));
195
+ return;
196
+ case "/api/tools":
197
+ this.sendJson(response, { tools: await this.store.tools() });
198
+ return;
199
+ case "/events":
200
+ this.subscribe(request, response);
201
+ return;
202
+ default:
203
+ this.send(response, 404, "text/plain; charset=utf-8", "Not found");
204
+ }
205
+ }
206
+ catch (error) {
207
+ this.send(response, 500, "text/plain; charset=utf-8", `Could not read the log: ${error instanceof Error ? error.message : String(error)}`);
208
+ }
209
+ }
210
+ parseQuery(params) {
211
+ return {
212
+ page: Number(params.get("page") ?? 1),
213
+ size: Number(params.get("size") ?? Paging.DEFAULT_SIZE),
214
+ tool: params.get("tool") || undefined,
215
+ failedOnly: params.get("failed") === "1",
216
+ text: params.get("q") || undefined,
217
+ };
218
+ }
219
+ subscribe(request, response) {
220
+ response.writeHead(200, {
221
+ ...this.securityHeaders(),
222
+ "content-type": "text/event-stream; charset=utf-8",
223
+ "cache-control": "no-store",
224
+ connection: "keep-alive",
225
+ });
226
+ response.socket?.unref();
227
+ // Tell the browser to retry after 2 s if the server restarts.
228
+ response.write("retry: 2000\n\nevent: ready\ndata: {}\n\n");
229
+ this.clients.add(response);
230
+ request.on("close", () => this.clients.delete(response));
231
+ }
232
+ sendJson(response, value) {
233
+ this.send(response, 200, "application/json; charset=utf-8", this.serializer.stringify(value, 0));
234
+ }
235
+ send(response, status, contentType, body, extra = {}) {
236
+ response.writeHead(status, { ...this.securityHeaders(), "content-type": contentType, ...extra });
237
+ response.end(body);
238
+ }
239
+ /**
240
+ * The page runs with a policy allowing only its own script, stylesheet,
241
+ * API and event stream, so even a result that somehow reached the page as
242
+ * markup could not load or run anything.
243
+ */
244
+ securityHeaders() {
245
+ return {
246
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
247
+ "x-content-type-options": "nosniff",
248
+ "referrer-policy": "no-referrer",
249
+ "cache-control": "no-store",
250
+ };
251
+ }
252
+ /** 0.0.0.0 is where it listens, not an address a browser can open. */
253
+ displayHost() {
254
+ return this.host === "0.0.0.0" || this.host === "::" ? "127.0.0.1" : this.host;
255
+ }
256
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Brings the live viewer up on the first tool call, and says so when it cannot.
3
+ *
4
+ * A decorator around the call logger's observer. Before each call it asks the
5
+ * viewer to be running, so the call itself appears in the page, and a port
6
+ * freed since the last attempt is picked up without a restart.
7
+ *
8
+ * The person using the chat is told, in the tool result itself, twice at most:
9
+ *
10
+ * - **the first time the port is unavailable**, with who holds it and what
11
+ * to do about it, since otherwise the viewer silently shows nothing;
12
+ * - **when it comes up after having been unavailable**, with its URL.
13
+ *
14
+ * Both go to the diagnostic log as well. Later failures while the port stays
15
+ * taken are not repeated in the chat, where the same line on every result
16
+ * would be noise the model has to read past.
17
+ */
18
+ export class LiveViewerObserver {
19
+ inner;
20
+ viewer;
21
+ fallback;
22
+ logger;
23
+ lastState = "idle";
24
+ toldUnavailable = false;
25
+ constructor(inner, viewer,
26
+ /** Where calls are still going while the viewer is down, e.g. "stderr". */
27
+ fallback, logger) {
28
+ this.inner = inner;
29
+ this.viewer = viewer;
30
+ this.fallback = fallback;
31
+ this.logger = logger;
32
+ }
33
+ async observe(tool, args, run) {
34
+ const status = await this.viewer.ensureRunning().catch(() => ({ state: "idle" }));
35
+ const notice = this.noticeFor(status);
36
+ const result = await this.inner.observe(tool, args, run);
37
+ if (!notice) {
38
+ return result;
39
+ }
40
+ return { ...result, content: [...result.content, { type: "text", text: notice }] };
41
+ }
42
+ /** @returns a line for the chat, or null. Logs every change of state either way. */
43
+ noticeFor(status) {
44
+ const previous = this.lastState;
45
+ this.lastState = status.state;
46
+ if (status.state === "unavailable") {
47
+ const message = `Live log viewer unavailable: ${status.reason}. Free that port, or set DB_LOG_PORT to another one. Calls are still logged to ${this.fallback}; the viewer starts on the next call once the port is free.`;
48
+ if (previous !== "unavailable") {
49
+ this.logger(message);
50
+ }
51
+ if (!this.toldUnavailable) {
52
+ this.toldUnavailable = true;
53
+ return `[mcp-db-read-only] ${message}`;
54
+ }
55
+ return null;
56
+ }
57
+ if (status.state === "running" && previous === "unavailable") {
58
+ return `[mcp-db-read-only] Live log viewer is now running at ${status.url}`;
59
+ }
60
+ return null;
61
+ }
62
+ }