simple-dynamsoft-mcp 7.5.0 → 7.6.1

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/.env.example CHANGED
@@ -43,5 +43,24 @@
43
43
  # MCP_DATA_DOWNLOAD_RETRY_MAX_DELAY_MS=5000
44
44
 
45
45
  # Optional: observability
46
+ # Console logs go to stderr. Format: json (default, one JSON object per line) | text (readable lines)
47
+ # MCP_LOG_FORMAT=json
46
48
  # MCP_VERBOSE_LOGS=false
47
49
  # MCP_LOG_LEVEL=info
50
+ # Request logs (one per tools/call / resources/read). stderr gets a summary:
51
+ # tool or uri, outcome, duration, response size, and requester info (user agent, opt-in client id).
52
+ # MCP_LOG_REQUESTS=true
53
+ # Detailed request log directory: daily requests-<YYYY-MM-DD>-<host>.jsonl files with
54
+ # arguments and full response. Unset = no detailed log.
55
+ # MCP_REQUEST_LOG_DIR=
56
+ # Delete detailed log files older than N days (0 = keep forever)
57
+ # MCP_REQUEST_LOG_RETENTION_DAYS=30
58
+ # Max JSON chars per logged arguments/response field in the detailed log (0 = no limit)
59
+ # MCP_LOG_MAX_FIELD_CHARS=0
60
+ # HTTP requester identity (personal data, opt-in): off (default) | hash (salted client_id) | full (client_ip)
61
+ # MCP_LOG_CLIENT_IP=off
62
+ # Secret salt for client_id; set it (e.g. as an ACA secret) for ids that stay stable across restarts.
63
+ # If unset, a per-process salt is used. Never store it next to the request logs.
64
+ # MCP_LOG_IP_SALT=
65
+ # Honor X-Forwarded-For (right-most entry) only when a trusted proxy sets it, e.g. Azure Container Apps ingress
66
+ # MCP_LOG_TRUST_PROXY=false
@@ -288,8 +288,8 @@
288
288
  "branch": "main",
289
289
  "owner": "Dynamsoft",
290
290
  "repo": "capture-vision-nodejs-samples",
291
- "commit": "ae8c6ce25cc5c71c82d91a3dc6e432b942e81e0a",
292
- "archiveUrl": "https://codeload.github.com/Dynamsoft/capture-vision-nodejs-samples/zip/ae8c6ce25cc5c71c82d91a3dc6e432b942e81e0a"
291
+ "commit": "11736e62e19b84f21db6075ada7798a6d692051a",
292
+ "archiveUrl": "https://codeload.github.com/Dynamsoft/capture-vision-nodejs-samples/zip/11736e62e19b84f21db6075ada7798a6d692051a"
293
293
  },
294
294
  {
295
295
  "name": "data/samples/dynamsoft-capture-vision-python",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-dynamsoft-mcp",
3
- "version": "7.5.0",
3
+ "version": "7.6.1",
4
4
  "description": "MCP server for Dynamsoft SDKs - Capture Vision, Barcode Reader (Mobile/Python/Web), Dynamic Web TWAIN, and Document Viewer. Provides documentation, code snippets, and API guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
25
  "start": "node src/index.js",
26
26
  "test": "npm run test:lite",
27
- "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js",
27
+ "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js",
28
28
  "test:lite": "npm run test:stdio && npm run test:http && npm run test:package",
29
29
  "test:lexical": "node --test test/integration/stdio.test.js test/integration/http.test.js",
30
30
  "test:gemini": "node scripts/run-gemini-tests.mjs",
@@ -34,7 +34,7 @@
34
34
  "test:contract": "node --test test/contract/contract.test.js",
35
35
  "check:version-drift": "node scripts/check-version-drift.mjs",
36
36
  "test:lazy": "node --test test/integration/lazy-hydration.test.js",
37
- "test:regression:unit": "node --test test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/profile-config.test.js test/unit/hydration-mode.test.js test/unit/logging.test.js",
37
+ "test:regression:unit": "node --test test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/profile-config.test.js test/unit/hydration-mode.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js",
38
38
  "test:regression:integration": "node --test test/integration/package-runtime.test.js test/integration/lazy-hydration.test.js",
39
39
  "test:regression": "npm run test:regression:unit && npm run test:regression:integration",
40
40
  "data:bootstrap": "git submodule sync --recursive && git submodule update --init --recursive --jobs 1",
@@ -2,14 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { logEvent } from "../observability/logging.js";
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const projectRoot = join(__dirname, "..", "..");
8
9
 
9
- function logDataSync(message) {
10
- console.error(`[data-sync] ${message}`);
11
- }
12
-
13
10
  function readBoolEnv(key, fallback = false) {
14
11
  const value = process.env[key];
15
12
  if (value === undefined || value === "") return fallback;
@@ -111,27 +108,30 @@ async function maybeSyncSubmodulesOnStart() {
111
108
  const timeoutMs = readIntEnv("DATA_SYNC_TIMEOUT_MS", 30000);
112
109
  const gitmodulesPath = join(projectRoot, ".gitmodules");
113
110
  const entries = parseGitmodules(gitmodulesPath);
114
- logDataSync(`start entries=${entries.length} timeout_ms=${timeoutMs}`);
111
+ logEvent("data-sync", "start", { entries: entries.length, timeout_ms: timeoutMs });
115
112
  if (entries.length === 0) {
116
- logDataSync("no submodules found; skipping");
113
+ logEvent("data-sync", "skipped", { reason: "no_submodules" });
117
114
  return;
118
115
  }
119
116
 
120
117
  let okCount = 0;
121
118
  let failCount = 0;
122
119
  for (const entry of entries) {
123
- logDataSync(`sync path=${entry.path} branch=${entry.branch}`);
120
+ logEvent("data-sync", "sync", { path: entry.path, branch: entry.branch });
124
121
  const result = syncSubmodule(entry, timeoutMs);
125
122
  if (!result.ok) {
126
123
  failCount += 1;
127
- const details = result.message ? `: ${result.message}` : "";
128
- console.error(`[data-sync] ${entry.path} ${result.step} failed${details}`);
124
+ logEvent("data-sync", "sync_failed", {
125
+ path: entry.path,
126
+ step: result.step,
127
+ message: result.message
128
+ }, { level: "error" });
129
129
  continue;
130
130
  }
131
131
  okCount += 1;
132
- logDataSync(`ok path=${entry.path}`);
132
+ logEvent("data-sync", "sync_ok", { path: entry.path });
133
133
  }
134
- logDataSync(`done ok=${okCount} failed=${failCount}`);
134
+ logEvent("data-sync", "done", { ok: okCount, failed: failCount });
135
135
  }
136
136
 
137
137
  export {
@@ -1,3 +1,12 @@
1
+ // Structured logging on stderr (stdout is reserved for the stdio MCP
2
+ // transport). Every entry carries ts, level, component, and event; caller
3
+ // fields are merged in. MCP_LOG_FORMAT selects the console rendering:
4
+ // "json" (default, one JSON object per line) or "text" (one human-readable
5
+ // line). Detailed request log files are always JSON (see request-log-file.js).
6
+
7
+ const RESERVED_KEYS = new Set(["ts", "level", "component", "event"]);
8
+ const REQUEST_EVENTS = new Set(["tool_call", "resource_read"]);
9
+
1
10
  function normalizeEnvValue(value) {
2
11
  if (value === undefined || value === null) return "";
3
12
  return String(value).trim().toLowerCase();
@@ -10,11 +19,8 @@ function isVerboseLoggingEnabled(env = process.env) {
10
19
  return ["1", "true", "yes", "on"].includes(toggle);
11
20
  }
12
21
 
13
- function quoteValue(value) {
14
- const raw = String(value ?? "");
15
- if (raw.length === 0) return "\"\"";
16
- if (/\s|=/.test(raw)) return JSON.stringify(raw);
17
- return raw;
22
+ function resolveLogFormat(env = process.env) {
23
+ return normalizeEnvValue(env.MCP_LOG_FORMAT) === "text" ? "text" : "json";
18
24
  }
19
25
 
20
26
  function latencyBucket(latencyMs) {
@@ -27,25 +33,99 @@ function latencyBucket(latencyMs) {
27
33
  return "ge3s";
28
34
  }
29
35
 
30
- function logEvent(component, event, fields = {}, options = {}) {
31
- const level = normalizeEnvValue(options.level || "info") || "info";
32
- const verbose = isVerboseLoggingEnabled(options.env);
33
- if (level === "debug" && !verbose) return;
36
+ function jsonReplacer(_key, value) {
37
+ if (typeof value === "bigint") return value.toString();
38
+ if (value instanceof Error) return { name: value.name, message: value.message };
39
+ return value;
40
+ }
34
41
 
35
- const parts = [`[${component}]`, `event=${quoteValue(event || "detail")}`];
42
+ function serializeLogEntry(entry) {
43
+ try {
44
+ return JSON.stringify(entry, jsonReplacer);
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return JSON.stringify({
48
+ ts: entry.ts,
49
+ level: entry.level,
50
+ component: entry.component,
51
+ event: entry.event,
52
+ serialization_error: message
53
+ });
54
+ }
55
+ }
56
+
57
+ function createLogEntry(component, event, fields = {}, level = "info") {
58
+ const entry = {
59
+ ts: new Date().toISOString(),
60
+ level,
61
+ component: String(component || "app"),
62
+ event: String(event || "detail")
63
+ };
36
64
  for (const [key, value] of Object.entries(fields || {})) {
37
65
  if (value === undefined || value === null || value === "") continue;
38
- parts.push(`${key}=${quoteValue(value)}`);
66
+ if (RESERVED_KEYS.has(key)) continue;
67
+ entry[key] = value;
68
+ }
69
+ return entry;
70
+ }
71
+
72
+ function formatTextValue(value) {
73
+ if (typeof value === "string") {
74
+ return value === "" || /[\s="\x00-\x1f]/.test(value) ? JSON.stringify(value) : value;
39
75
  }
76
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
77
+ return JSON.stringify(value, jsonReplacer);
78
+ }
79
+
80
+ // "search ok 374ms 1804 chars" / "read doc://x error -32603 \"msg\" 1ms".
81
+ function formatRequestHead(entry, consumed) {
82
+ const parts = [];
83
+ if (entry.event === "tool_call") {
84
+ parts.push(formatTextValue(entry.tool ?? "?"));
85
+ consumed.add("tool");
86
+ } else {
87
+ parts.push("read", formatTextValue(entry.uri ?? "?"));
88
+ consumed.add("uri");
89
+ }
90
+ parts.push(entry.send_failed ? "undelivered" : entry.is_error ? "error" : "ok");
91
+ if (entry.error) {
92
+ parts.push(String(entry.error.code), formatTextValue(String(entry.error.message ?? "")));
93
+ }
94
+ if (entry.duration_ms !== undefined) parts.push(`${entry.duration_ms}ms`);
95
+ if (entry.response_chars !== undefined) parts.push(`${entry.response_chars} chars`);
96
+ for (const key of ["is_error", "error", "duration_ms", "response_chars", "send_failed"]) consumed.add(key);
97
+ return parts;
98
+ }
40
99
 
41
- if (level !== "info") {
42
- parts.push(`level=${level}`);
100
+ function formatTextLine(entry) {
101
+ const parts = [entry.ts, entry.level.toUpperCase().padEnd(5), `[${entry.component}]`];
102
+ const consumed = new Set(RESERVED_KEYS);
103
+ if (entry.component === "mcp" && REQUEST_EVENTS.has(entry.event)) {
104
+ parts.push(...formatRequestHead(entry, consumed));
105
+ } else {
106
+ parts.push(entry.event);
107
+ }
108
+ for (const [key, value] of Object.entries(entry)) {
109
+ if (!consumed.has(key)) parts.push(`${key}=${formatTextValue(value)}`);
43
110
  }
44
- console.error(parts.join(" "));
111
+ return parts.join(" ");
112
+ }
113
+
114
+ function logEvent(component, event, fields = {}, options = {}) {
115
+ const env = options.env || process.env;
116
+ const level = normalizeEnvValue(options.level || "info") || "info";
117
+ const verbose = isVerboseLoggingEnabled(env);
118
+ if (level === "debug" && !verbose) return;
119
+
120
+ const entry = createLogEntry(component, event, fields, level);
121
+ console.error(resolveLogFormat(env) === "text" ? formatTextLine(entry) : serializeLogEntry(entry));
45
122
  }
46
123
 
47
124
  export {
125
+ createLogEntry,
126
+ formatTextLine,
48
127
  isVerboseLoggingEnabled,
49
128
  latencyBucket,
50
- logEvent
129
+ logEvent,
130
+ serializeLogEntry
51
131
  };
@@ -0,0 +1,267 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir, readdir, unlink } from "node:fs/promises";
3
+ import { hostname } from "node:os";
4
+ import { join } from "node:path";
5
+ import { logEvent, serializeLogEntry } from "./logging.js";
6
+
7
+ const FILE_PATTERN = /^requests-(\d{4}-\d{2}-\d{2})-.+\.jsonl$/;
8
+ const DEFAULT_RETENTION_DAYS = 30;
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+ // Cap on entries queued in memory while the log volume is slow or stalled.
11
+ const DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
12
+
13
+ function resolveRetentionDays(env = process.env) {
14
+ const raw = env.MCP_REQUEST_LOG_RETENTION_DAYS;
15
+ if (raw === undefined || raw === "") return DEFAULT_RETENTION_DAYS;
16
+ const parsed = Number.parseInt(String(raw), 10);
17
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RETENTION_DAYS;
18
+ }
19
+
20
+ function sanitizeFilePart(value) {
21
+ return String(value || "").replace(/[^A-Za-z0-9._-]/g, "_") || "host";
22
+ }
23
+
24
+ function utcDay(ms) {
25
+ return new Date(ms).toISOString().slice(0, 10);
26
+ }
27
+
28
+ // Appends detailed request entries as JSON lines to
29
+ // <dir>/requests-<YYYY-MM-DD>-<host>.jsonl (UTC day). One file per host keeps
30
+ // replicas sharing a network volume from interleaving writes. Files older than
31
+ // retentionDays are removed when a new day's file is opened (0 keeps all).
32
+ //
33
+ // Logging must never disrupt requests:
34
+ // - Failures are reported on stderr once per day (for whichever day's stream
35
+ // failed, including streams already rotated out or closing) and never thrown.
36
+ // A failing old stream never disables its replacement.
37
+ // - If the volume stalls, entries are dropped once maxBufferedBytes are queued
38
+ // across every day's file not yet closed (entries waiting for the file to
39
+ // open plus bytes queued in its stream, including a rotated-out file still
40
+ // flushing), so memory stays bounded. Dropping is reported once per stall,
41
+ // and resuming (with the dropped count) on the next write that fits. A
42
+ // single entry larger than the cap is dropped and reported with its size. A
43
+ // cap of 0 queues nothing.
44
+ // - No blocking filesystem calls run on the request path: the directory is
45
+ // created asynchronously, and entries logged meanwhile are queued (within
46
+ // the cap) and written in order once the file opens.
47
+ function createRequestLogFile({
48
+ dir,
49
+ retentionDays = DEFAULT_RETENTION_DAYS,
50
+ host = hostname(),
51
+ now = Date.now,
52
+ maxBufferedBytes = DEFAULT_MAX_BUFFERED_BYTES,
53
+ makeDir = (path) => mkdir(path, { recursive: true }),
54
+ openStream = (path) => createWriteStream(path, { flags: "a" })
55
+ }) {
56
+ const hostPart = sanitizeFilePart(host);
57
+ const reportedDays = new Set();
58
+ // One sink per day's file. Entries logged before the directory exists and
59
+ // the stream is open wait in sink.pending; afterwards they queue in the
60
+ // stream. A sink stays in `sinks` (and counts against the cap) until its
61
+ // stream closes or its open fails, including after rotation.
62
+ const sinks = new Set();
63
+ let current = null;
64
+ let failedDay = "";
65
+ let droppedEntries = 0;
66
+ let cleanup = Promise.resolve();
67
+
68
+ function reportFailure(day, error) {
69
+ if (reportedDays.has(day)) return;
70
+ reportedDays.add(day);
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ logEvent("request-log", "file_write_failed", { dir, day, message }, { level: "error" });
73
+ }
74
+
75
+ function sinkBytes(sink) {
76
+ const streamBytes = sink.stream && !sink.stream.destroyed ? sink.stream.writableLength || 0 : 0;
77
+ return sink.pendingBytes + streamBytes;
78
+ }
79
+
80
+ function queuedBytes() {
81
+ let total = 0;
82
+ for (const sink of sinks) total += sinkBytes(sink);
83
+ return total;
84
+ }
85
+
86
+ function discardPending(sink) {
87
+ sink.pending = [];
88
+ sink.pendingBytes = 0;
89
+ }
90
+
91
+ function disableDay(day) {
92
+ failedDay = day;
93
+ const failed = current;
94
+ current = null;
95
+ if (!failed) return;
96
+ discardPending(failed);
97
+ failed.stream?.destroy();
98
+ }
99
+
100
+ function resumeAfterBackpressure() {
101
+ if (!droppedEntries) return;
102
+ logEvent("request-log", "file_backpressure_resumed", { dir, dropped_entries: droppedEntries }, { level: "warn" });
103
+ droppedEntries = 0;
104
+ }
105
+
106
+ async function removeExpired(today) {
107
+ if (!retentionDays) return;
108
+ const cutoff = Date.parse(`${today}T00:00:00Z`) - retentionDays * DAY_MS;
109
+ let names;
110
+ try {
111
+ names = await readdir(dir);
112
+ } catch (error) {
113
+ logEvent("request-log", "retention_failed", { dir, message: error instanceof Error ? error.message : String(error) }, { level: "warn" });
114
+ return;
115
+ }
116
+ for (const name of names) {
117
+ const match = FILE_PATTERN.exec(name);
118
+ if (!match || Date.parse(`${match[1]}T00:00:00Z`) >= cutoff) continue;
119
+ try {
120
+ await unlink(join(dir, name));
121
+ } catch (error) {
122
+ logEvent("request-log", "retention_failed", { file: name, message: error instanceof Error ? error.message : String(error) }, { level: "warn" });
123
+ }
124
+ }
125
+ }
126
+
127
+ // Directory creation is async so a slow or stalled volume never blocks the
128
+ // event loop on the request path.
129
+ function createSink(day) {
130
+ const sink = { day, stream: null, pending: [], pendingBytes: 0, ended: false };
131
+ let markDone;
132
+ sink.done = new Promise((resolve) => {
133
+ markDone = resolve;
134
+ });
135
+ const finish = () => {
136
+ sinks.delete(sink);
137
+ markDone();
138
+ };
139
+ sinks.add(sink);
140
+
141
+ sink.ready = Promise.resolve()
142
+ .then(() => makeDir(dir))
143
+ .then(() => {
144
+ if (failedDay === day) {
145
+ finish();
146
+ return;
147
+ }
148
+ const stream = openStream(join(dir, `requests-${day}-${hostPart}.jsonl`));
149
+ stream.on("error", (error) => {
150
+ reportFailure(day, error);
151
+ if (current === sink) disableDay(day);
152
+ });
153
+ stream.once("close", finish);
154
+ sink.stream = stream;
155
+ for (const line of sink.pending) stream.write(line);
156
+ discardPending(sink);
157
+ if (sink.ended) stream.end();
158
+ cleanup = removeExpired(day);
159
+ })
160
+ .catch((error) => {
161
+ reportFailure(day, error);
162
+ if (current === sink) disableDay(day);
163
+ discardPending(sink);
164
+ if (!sink.stream) finish();
165
+ });
166
+ return sink;
167
+ }
168
+
169
+ function endSink(sink) {
170
+ sink.ended = true;
171
+ sink.stream?.end();
172
+ }
173
+
174
+ function write(entry) {
175
+ const day = utcDay(now());
176
+ if (day === failedDay) return;
177
+ try {
178
+ if (!current || current.day !== day) {
179
+ if (current) endSink(current);
180
+ current = createSink(day);
181
+ }
182
+ const line = `${serializeLogEntry(entry)}\n`;
183
+ const lineBytes = Buffer.byteLength(line);
184
+ if (lineBytes > maxBufferedBytes) {
185
+ logEvent("request-log", "file_entry_too_large", {
186
+ dir,
187
+ day,
188
+ entry_bytes: lineBytes,
189
+ limit_bytes: maxBufferedBytes
190
+ }, { level: "warn" });
191
+ return;
192
+ }
193
+ const queued = queuedBytes();
194
+ if (queued + lineBytes > maxBufferedBytes) {
195
+ if (!droppedEntries) {
196
+ logEvent("request-log", "file_backpressure_dropping", {
197
+ dir,
198
+ day,
199
+ buffered_bytes: queued,
200
+ limit_bytes: maxBufferedBytes
201
+ }, { level: "warn" });
202
+ }
203
+ droppedEntries += 1;
204
+ return;
205
+ }
206
+ resumeAfterBackpressure();
207
+ if (current.stream) {
208
+ current.stream.write(line);
209
+ } else {
210
+ current.pending.push(line);
211
+ current.pendingBytes += lineBytes;
212
+ }
213
+ } catch (error) {
214
+ reportFailure(day, error);
215
+ disableDay(day);
216
+ }
217
+ }
218
+
219
+ // Ends the current day's file and resolves once every sink has finished:
220
+ // pending opens complete and flush, and rotated-out streams still flushing
221
+ // close, so shutdown does not drop entries. Errors are reported by each
222
+ // stream's handler; "close" follows them, so this still resolves.
223
+ function close() {
224
+ const last = current;
225
+ current = null;
226
+ if (last) endSink(last);
227
+ return Promise.all([...sinks].map((sink) => sink.done)).then(() => undefined);
228
+ }
229
+
230
+ async function whenCleanupDone() {
231
+ await Promise.all([...sinks].map((sink) => sink.ready));
232
+ await cleanup;
233
+ }
234
+
235
+ return {
236
+ write,
237
+ close,
238
+ whenCleanupDone
239
+ };
240
+ }
241
+
242
+ let sharedFile = null;
243
+ let sharedDir = "";
244
+
245
+ // Process-wide writer for MCP_REQUEST_LOG_DIR; null when file logging is off.
246
+ function getRequestLogFile(env = process.env) {
247
+ const dir = String(env.MCP_REQUEST_LOG_DIR || "").trim();
248
+ if (!dir) return null;
249
+ if (!sharedFile || sharedDir !== dir) {
250
+ sharedFile = createRequestLogFile({ dir, retentionDays: resolveRetentionDays(env) });
251
+ sharedDir = dir;
252
+ }
253
+ return sharedFile;
254
+ }
255
+
256
+ async function closeRequestLogFile() {
257
+ const file = sharedFile;
258
+ sharedFile = null;
259
+ sharedDir = "";
260
+ await file?.close();
261
+ }
262
+
263
+ export {
264
+ closeRequestLogFile,
265
+ createRequestLogFile,
266
+ getRequestLogFile
267
+ };
@@ -0,0 +1,220 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { createLogEntry, logEvent } from "./logging.js";
3
+ import { getRequestLogFile } from "./request-log-file.js";
4
+
5
+ // JSON-RPC methods whose parameters and responses are logged. These are the
6
+ // user-driven operations worth reviewing (what was asked, what was returned).
7
+ const LOGGED_METHODS = new Map([
8
+ ["tools/call", "tool_call"],
9
+ ["resources/read", "resource_read"]
10
+ ]);
11
+
12
+ // Detailed entries go to disk, so arguments/responses are kept whole unless
13
+ // MCP_LOG_MAX_FIELD_CHARS sets a cap.
14
+ const DEFAULT_MAX_FIELD_CHARS = 0;
15
+ const MAX_PENDING_REQUESTS = 1000;
16
+ const MAX_USER_AGENT_CHARS = 256;
17
+ // Cap for caller-controlled scalars (uri, tool name, JSON-RPC id).
18
+ const MAX_SCALAR_CHARS = 1024;
19
+ const CLIENT_IP_MODES = new Set(["hash", "full", "off"]);
20
+
21
+ // Salt for hashed client ids, so they are not reversible by brute forcing the
22
+ // IPv4 space without it. Use MCP_LOG_IP_SALT (a deployment secret) for ids that
23
+ // are stable across restarts and replicas. Without it, a per-process salt is
24
+ // used: ids only group requests within one process lifetime. The salt is never
25
+ // written next to the logs it de-identifies.
26
+ const processSalt = randomBytes(32).toString("hex");
27
+
28
+ function resolveIpSalt(env = process.env) {
29
+ return env.MCP_LOG_IP_SALT || processSalt;
30
+ }
31
+
32
+ function readBoolEnv(env, key, fallback) {
33
+ const value = env[key];
34
+ if (value === undefined || value === "") return fallback;
35
+ return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
36
+ }
37
+
38
+ function isRequestLoggingEnabled(env = process.env) {
39
+ return readBoolEnv(env, "MCP_LOG_REQUESTS", true);
40
+ }
41
+
42
+ function resolveMaxFieldChars(env = process.env) {
43
+ const raw = env.MCP_LOG_MAX_FIELD_CHARS;
44
+ if (raw === undefined || raw === "") return DEFAULT_MAX_FIELD_CHARS;
45
+ const parsed = Number.parseInt(String(raw), 10);
46
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_MAX_FIELD_CHARS;
47
+ }
48
+
49
+ // IP-derived fields are personal data, so they are opt-in.
50
+ function resolveClientIpMode(env = process.env) {
51
+ const mode = String(env.MCP_LOG_CLIENT_IP || "").trim().toLowerCase();
52
+ return CLIENT_IP_MODES.has(mode) ? mode : "off";
53
+ }
54
+
55
+ // Bounds a logged value when a cap is configured. 0 disables the limit.
56
+ function limitForLog(value, maxChars) {
57
+ if (value === undefined || !maxChars) return value;
58
+ let text;
59
+ try {
60
+ text = JSON.stringify(value);
61
+ } catch {
62
+ return "[unserializable]";
63
+ }
64
+ if (text === undefined || text.length <= maxChars) return value;
65
+ return { truncated: true, original_chars: text.length, preview: text.slice(0, maxChars) };
66
+ }
67
+
68
+ // Bounds caller-controlled fields that are expected to be short (uri, tool
69
+ // name, JSON-RPC id, error message) even when the caller sends something else.
70
+ function capScalar(value) {
71
+ if (typeof value !== "string") return limitForLog(value, MAX_SCALAR_CHARS);
72
+ if (value.length <= MAX_SCALAR_CHARS) return value;
73
+ return `${value.slice(0, MAX_SCALAR_CHARS)}...[truncated ${value.length - MAX_SCALAR_CHARS} chars]`;
74
+ }
75
+
76
+ function normalizeIp(value) {
77
+ let ip = String(value || "").trim();
78
+ if (!ip) return "";
79
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(ip);
80
+ if (bracketed) ip = bracketed[1];
81
+ const ipv4WithPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(ip);
82
+ if (ipv4WithPort) ip = ipv4WithPort[1];
83
+ if (ip.toLowerCase().startsWith("::ffff:") && ip.includes(".")) ip = ip.slice(7);
84
+ return ip;
85
+ }
86
+
87
+ // X-Forwarded-For is client-controlled unless a proxy in front of the server
88
+ // rewrites it, so it is only honored with MCP_LOG_TRUST_PROXY=true (e.g. Azure
89
+ // Container Apps ingress). The trusted proxy appends the peer it saw, so the
90
+ // right-most entry is the one a client cannot spoof. Otherwise the socket peer
91
+ // is used.
92
+ function resolveClientIp(req, env = process.env) {
93
+ if (readBoolEnv(env, "MCP_LOG_TRUST_PROXY", false)) {
94
+ const forwarded = req?.headers?.["x-forwarded-for"];
95
+ const header = Array.isArray(forwarded) ? forwarded.join(",") : forwarded;
96
+ if (header) {
97
+ const parts = String(header).split(",").map((part) => part.trim()).filter(Boolean);
98
+ if (parts.length > 0) return normalizeIp(parts[parts.length - 1]);
99
+ }
100
+ }
101
+ return normalizeIp(req?.socket?.remoteAddress);
102
+ }
103
+
104
+ function describeClientIp(ip, env = process.env) {
105
+ if (!ip) return {};
106
+ const mode = resolveClientIpMode(env);
107
+ if (mode === "off") return {};
108
+ if (mode === "full") return { client_ip: ip };
109
+ const salt = resolveIpSalt(env);
110
+ return { client_id: createHmac("sha256", salt).update(ip).digest("hex").slice(0, 16) };
111
+ }
112
+
113
+ function buildHttpRequestContext(req, env = process.env) {
114
+ const context = { transport: "http" };
115
+ if (resolveClientIpMode(env) !== "off") {
116
+ Object.assign(context, describeClientIp(resolveClientIp(req, env), env));
117
+ }
118
+ const userAgent = req?.headers?.["user-agent"];
119
+ if (userAgent) context.user_agent = String(userAgent).slice(0, MAX_USER_AGENT_CHARS);
120
+ return context;
121
+ }
122
+
123
+ function isResponse(message) {
124
+ return Boolean(message) && message.id !== undefined && message.id !== null
125
+ && ("result" in message || "error" in message);
126
+ }
127
+
128
+ function jsonLength(value) {
129
+ try {
130
+ return JSON.stringify(value)?.length ?? 0;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ // One entry per tools/call or resources/read. stderr gets a short summary
137
+ // (what was called, outcome, duration, response size). The detailed entry
138
+ // (arguments, full response, caller context) is appended to the request log
139
+ // file when MCP_REQUEST_LOG_DIR is set. Hooks the transport so every tool and
140
+ // resource handler is covered without per-handler code. Safe to call before or
141
+ // after server.connect(transport).
142
+ function attachRequestLogging(transport, context = {}, {
143
+ env = process.env,
144
+ now = Date.now,
145
+ file = getRequestLogFile(env)
146
+ } = {}) {
147
+ if (!isRequestLoggingEnabled(env)) return transport;
148
+ const maxChars = resolveMaxFieldChars(env);
149
+ const pending = new Map();
150
+
151
+ const previousOnMessage = transport.onmessage;
152
+ transport.onmessage = (message, extra) => {
153
+ const event = message && message.id !== undefined ? LOGGED_METHODS.get(message.method) : undefined;
154
+ if (event) {
155
+ if (pending.size >= MAX_PENDING_REQUESTS) {
156
+ pending.delete(pending.keys().next().value);
157
+ }
158
+ pending.set(message.id, { event, params: message.params || {}, startedAt: now() });
159
+ }
160
+ previousOnMessage?.(message, extra);
161
+ };
162
+
163
+ function record(message, request, durationMs, sendError) {
164
+ const params = request.params;
165
+ const isToolCall = request.event === "tool_call";
166
+ const rpcError = message.error;
167
+ const level = rpcError || sendError ? "warn" : "info";
168
+ const summary = {
169
+ ...context,
170
+ request_id: capScalar(message.id),
171
+ tool: isToolCall ? capScalar(params.name) : undefined,
172
+ uri: isToolCall ? undefined : capScalar(params.uri),
173
+ duration_ms: durationMs,
174
+ is_error: Boolean(rpcError) || message.result?.isError === true,
175
+ error: rpcError ? { code: rpcError.code, message: capScalar(rpcError.message) } : undefined,
176
+ send_failed: sendError ? true : undefined,
177
+ send_error: sendError ? capScalar(sendError instanceof Error ? sendError.message : String(sendError)) : undefined
178
+ };
179
+
180
+ logEvent("mcp", request.event, {
181
+ ...summary,
182
+ response_chars: rpcError ? undefined : jsonLength(message.result)
183
+ }, { level, env });
184
+
185
+ file?.write(createLogEntry("mcp", request.event, {
186
+ ...summary,
187
+ arguments: isToolCall ? limitForLog(params.arguments || {}, maxChars) : undefined,
188
+ response: rpcError ? undefined : limitForLog(message.result, maxChars)
189
+ }, level));
190
+ }
191
+
192
+ // Logged after the transport accepts the response, so an entry reflects
193
+ // delivery; a rejected send is logged with send_failed and rethrown.
194
+ // duration_ms measures handler time (request received -> response ready).
195
+ const originalSend = transport.send.bind(transport);
196
+ transport.send = async (message, options) => {
197
+ const request = isResponse(message) ? pending.get(message.id) : undefined;
198
+ if (!request) return originalSend(message, options);
199
+ pending.delete(message.id);
200
+ const durationMs = now() - request.startedAt;
201
+ try {
202
+ const result = await originalSend(message, options);
203
+ record(message, request, durationMs, null);
204
+ return result;
205
+ } catch (error) {
206
+ record(message, request, durationMs, error);
207
+ throw error;
208
+ }
209
+ };
210
+
211
+ return transport;
212
+ }
213
+
214
+ export {
215
+ attachRequestLogging,
216
+ buildHttpRequestContext,
217
+ describeClientIp,
218
+ limitForLog,
219
+ resolveClientIp
220
+ };
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- import { logEvent } from "../../observability/logging.js";
3
2
 
4
3
  function errorResult(text) {
5
4
  return { isError: true, content: [{ type: "text", text }] };
@@ -53,13 +52,11 @@ export function registerDocumentTools({ server, readIndexedResource }) {
53
52
  resource = await readIndexedResource(uri);
54
53
  } catch (error) {
55
54
  const message = error instanceof Error ? error.message : String(error);
56
- logEvent("read_document", "read_failed", { uri, error: message });
57
55
  return errorResult(`Failed to read document: ${message}`);
58
56
  }
59
57
 
60
58
  const text = typeof resource.text === "string" ? resource.text : "";
61
59
  if (!text.trim()) {
62
- logEvent("read_document", "empty_document", { uri });
63
60
  return errorResult(`Document ${uri} has no readable text content. Call search again to find an alternative document.`);
64
61
  }
65
62
 
@@ -9,7 +9,6 @@ import {
9
9
  } from "../public-offerings.js";
10
10
  import { buildUnsupportedPublicScopeResponse } from "./public-routing.js";
11
11
  import { validatePlatform, validateEdition } from "../normalizers.js";
12
- import { logEvent } from "../../observability/logging.js";
13
12
 
14
13
  export function registerIndexTools({
15
14
  server,
@@ -305,15 +304,6 @@ export function registerIndexTools({
305
304
  }
306
305
  }
307
306
 
308
- // Zero-result queries are the highest-value eval/synonym-gap signal — log
309
- // them so they can be harvested from production logs (issue #156).
310
- logEvent("search", "zero_result_query", {
311
- query,
312
- product: normalizedProduct || "",
313
- edition: normalizedEdition || "",
314
- platform: normalizedPlatform || "",
315
- type: type || "any"
316
- });
317
307
  return {
318
308
  content: [{
319
309
  type: "text",
@@ -1,6 +1,8 @@
1
1
  import { createServer as createHttpServer } from "node:http";
2
2
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
3
  import { logEvent } from "../../observability/logging.js";
4
+ import { attachRequestLogging, buildHttpRequestContext } from "../../observability/request-logging.js";
5
+ import { closeRequestLogFile } from "../../observability/request-log-file.js";
4
6
 
5
7
  async function startHttpServer({
6
8
  host,
@@ -74,9 +76,10 @@ async function startHttpServer({
74
76
 
75
77
  try {
76
78
  server = createServer();
77
- transport = new StreamableHTTPServerTransport({
78
- sessionIdGenerator: undefined
79
- });
79
+ transport = attachRequestLogging(
80
+ new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }),
81
+ buildHttpRequestContext(req)
82
+ );
80
83
  await server.connect(transport);
81
84
  await transport.handleRequest(req, res);
82
85
  } catch (error) {
@@ -110,6 +113,7 @@ async function startHttpServer({
110
113
  await new Promise((resolve) => {
111
114
  httpServer.close(() => resolve());
112
115
  });
116
+ await closeRequestLogFile();
113
117
  process.exit(0);
114
118
  };
115
119
 
@@ -1,10 +1,11 @@
1
1
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
2
  import { logEvent } from "../../observability/logging.js";
3
+ import { attachRequestLogging } from "../../observability/request-logging.js";
3
4
 
4
5
  async function startStdioServer({ createServer }) {
5
6
  logEvent("transport", "server_start", { mode: "stdio" });
6
7
  const server = createServer();
7
- const transport = new StdioServerTransport();
8
+ const transport = attachRequestLogging(new StdioServerTransport(), { transport: "stdio" });
8
9
  await server.connect(transport);
9
10
  return { server, transport };
10
11
  }