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 +19 -0
- package/data/metadata/data-manifest.json +2 -2
- package/package.json +3 -3
- package/src/data/submodule-sync.js +11 -11
- package/src/observability/logging.js +95 -15
- package/src/observability/request-log-file.js +267 -0
- package/src/observability/request-logging.js +220 -0
- package/src/server/tools/register-document-tools.js +0 -3
- package/src/server/tools/register-index-tools.js +0 -10
- package/src/server/transports/http.js +7 -3
- package/src/server/transports/stdio.js +2 -1
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": "
|
|
292
|
-
"archiveUrl": "https://codeload.github.com/Dynamsoft/capture-vision-nodejs-samples/zip/
|
|
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.
|
|
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
|
-
|
|
111
|
+
logEvent("data-sync", "start", { entries: entries.length, timeout_ms: timeoutMs });
|
|
115
112
|
if (entries.length === 0) {
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
+
logEvent("data-sync", "sync_ok", { path: entry.path });
|
|
133
133
|
}
|
|
134
|
-
|
|
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
|
|
14
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|