simple-dynamsoft-mcp 7.4.42 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/README.md +4 -1
- 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/create-server.js +17 -3
- package/src/server/resources/read-indexed-resource.js +33 -0
- package/src/server/resources/register-resources.js +3 -26
- package/src/server/tools/register-document-tools.js +66 -0
- package/src/server/tools/register-index-tools.js +7 -16
- package/src/server/tools/register-project-tools.js +2 -2
- 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
|
package/README.md
CHANGED
|
@@ -296,12 +296,15 @@ mcpServers:
|
|
|
296
296
|
The server exposes this minimal tool surface:
|
|
297
297
|
|
|
298
298
|
- `get_index` -- compact product/version/sample index with selection guidance
|
|
299
|
-
- `search` -- semantic search across docs and samples (also accepts exact sample IDs)
|
|
299
|
+
- `search` -- semantic search across docs and samples (also accepts exact sample IDs); returns `doc://` and `sample://` links
|
|
300
|
+
- `read_document` -- read the full text (title, public docs URL, body) of a `doc://` URI returned by `search`
|
|
300
301
|
- `list_samples` -- browse available samples for a product/edition/platform
|
|
301
302
|
- `resolve_version` -- resolve current version for a product/edition/platform
|
|
302
303
|
- `get_quickstart` -- opinionated quickstart: picks a sample by scenario, returns code + install instructions
|
|
303
304
|
- `get_sample_files` -- get full project files for a known sample (discovered via list_samples or search)
|
|
304
305
|
|
|
306
|
+
Typical doc Q&A flow: `search` -> `read_document` with the exact `doc://` URI -> answer from the returned text and cite its public URL. Clients that support MCP resources can also fetch the same `doc://` / `sample://` URIs with `resources/read`.
|
|
307
|
+
|
|
305
308
|
## Advanced Configuration And Operator Docs
|
|
306
309
|
|
|
307
310
|
Advanced settings, CI/runbook details, and maintenance workflows live in:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simple-dynamsoft-mcp",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
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
|
+
};
|
|
@@ -2,7 +2,9 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { ensureDataScopesHydrated } from "../data/bootstrap.js";
|
|
3
3
|
import { createScopeHydrator } from "./helpers/server-helpers.js";
|
|
4
4
|
import { registerResourceHandlers } from "./resources/register-resources.js";
|
|
5
|
+
import { createIndexedResourceReader } from "./resources/read-indexed-resource.js";
|
|
5
6
|
import { registerIndexTools } from "./tools/register-index-tools.js";
|
|
7
|
+
import { registerDocumentTools } from "./tools/register-document-tools.js";
|
|
6
8
|
import { registerSampleTools } from "./tools/register-sample-tools.js";
|
|
7
9
|
import { registerVersionTools } from "./tools/register-version-tools.js";
|
|
8
10
|
import { registerQuickstartTools } from "./tools/register-quickstart-tools.js";
|
|
@@ -71,6 +73,8 @@ export function createMcpServerInstance({ pkgVersion, resourceIndexApi, ragApi }
|
|
|
71
73
|
"",
|
|
72
74
|
"How to use it:",
|
|
73
75
|
"- Call get_index first to see valid product/edition/platform combinations before asking the user anything.",
|
|
76
|
+
"- To answer from documentation: call search, then call read_document with the exact doc:// uri it returned, and base the answer on that text, citing the public documentation URL it contains. Do not answer from search summaries alone or hand doc:// links to the user.",
|
|
77
|
+
"- For sample:// results, call get_sample_files with resource_uri to get the complete sample project.",
|
|
74
78
|
"- Only ask the user to disambiguate dimensions that actually vary for the chosen product. Do NOT ask about dimensions that don't apply.",
|
|
75
79
|
"",
|
|
76
80
|
"Platform/edition rules (important):",
|
|
@@ -84,6 +88,12 @@ export function createMcpServerInstance({ pkgVersion, resourceIndexApi, ragApi }
|
|
|
84
88
|
].join("\n")
|
|
85
89
|
});
|
|
86
90
|
|
|
91
|
+
const readIndexedResource = createIndexedResourceReader({
|
|
92
|
+
parseResourceUri,
|
|
93
|
+
ensureLatestMajor,
|
|
94
|
+
readResourceContent
|
|
95
|
+
});
|
|
96
|
+
|
|
87
97
|
registerIndexTools({
|
|
88
98
|
server,
|
|
89
99
|
ensureScopeHydrated,
|
|
@@ -100,6 +110,11 @@ export function createMcpServerInstance({ pkgVersion, resourceIndexApi, ragApi }
|
|
|
100
110
|
getSampleSuggestions
|
|
101
111
|
});
|
|
102
112
|
|
|
113
|
+
registerDocumentTools({
|
|
114
|
+
server,
|
|
115
|
+
readIndexedResource
|
|
116
|
+
});
|
|
117
|
+
|
|
103
118
|
registerSampleTools({
|
|
104
119
|
server,
|
|
105
120
|
registry,
|
|
@@ -183,9 +198,8 @@ export function createMcpServerInstance({ pkgVersion, resourceIndexApi, ragApi }
|
|
|
183
198
|
registerResourceHandlers({
|
|
184
199
|
server,
|
|
185
200
|
getPinnedResources,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
readResourceContent
|
|
201
|
+
readResourceContent,
|
|
202
|
+
readIndexedResource
|
|
189
203
|
});
|
|
190
204
|
|
|
191
205
|
return server;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Products whose versioned doc/sample URIs are subject to the latest-major policy.
|
|
2
|
+
const VERSION_POLICY_PRODUCTS = ["dbr", "dwt", "ddv", "mrz", "mds"];
|
|
3
|
+
|
|
4
|
+
// Shared read path for doc:// and sample:// URIs. Used by the MCP resource
|
|
5
|
+
// templates (resources/read) and the read_document tool so both enforce the
|
|
6
|
+
// same version policy and stale-URI guidance.
|
|
7
|
+
export function createIndexedResourceReader({ parseResourceUri, ensureLatestMajor, readResourceContent }) {
|
|
8
|
+
return async function readIndexedResource(uri) {
|
|
9
|
+
const uriStr = String(uri);
|
|
10
|
+
const parsed = parseResourceUri(uriStr);
|
|
11
|
+
if (parsed && VERSION_POLICY_PRODUCTS.includes(parsed.product)) {
|
|
12
|
+
const policy = ensureLatestMajor({
|
|
13
|
+
product: parsed.product,
|
|
14
|
+
version: parsed.version,
|
|
15
|
+
edition: parsed.edition,
|
|
16
|
+
platform: parsed.platform
|
|
17
|
+
});
|
|
18
|
+
if (!policy.ok) {
|
|
19
|
+
throw new Error(policy.message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const resource = await readResourceContent(uriStr);
|
|
24
|
+
if (!resource) {
|
|
25
|
+
// Doc URIs embed a positional index + exact version, so they can go stale
|
|
26
|
+
// across data refreshes. Point the agent back to search to re-discover the
|
|
27
|
+
// current URI instead of dead-ending (issue #153).
|
|
28
|
+
throw new Error(`Resource not found: ${uriStr}. The URI may be stale (doc URIs change across data refreshes). Call search with keywords from the title to get the current URI.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return resource;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -3,9 +3,8 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
export function registerResourceHandlers({
|
|
4
4
|
server,
|
|
5
5
|
getPinnedResources,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readResourceContent
|
|
6
|
+
readResourceContent,
|
|
7
|
+
readIndexedResource
|
|
9
8
|
}) {
|
|
10
9
|
for (const entry of getPinnedResources()) {
|
|
11
10
|
server.registerResource(
|
|
@@ -26,29 +25,7 @@ export function registerResourceHandlers({
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
async function templateReadHandler(uri) {
|
|
29
|
-
|
|
30
|
-
const parsed = parseResourceUri(uriStr);
|
|
31
|
-
if (parsed && ["dbr", "dwt", "ddv", "mrz", "mds"].includes(parsed.product)) {
|
|
32
|
-
const policy = ensureLatestMajor({
|
|
33
|
-
product: parsed.product,
|
|
34
|
-
version: parsed.version,
|
|
35
|
-
edition: parsed.edition,
|
|
36
|
-
platform: parsed.platform
|
|
37
|
-
});
|
|
38
|
-
if (!policy.ok) {
|
|
39
|
-
throw new Error(policy.message);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const resource = await readResourceContent(uriStr);
|
|
44
|
-
if (!resource) {
|
|
45
|
-
// Doc URIs embed a positional index + exact version, so they can go stale
|
|
46
|
-
// across data refreshes. Point the agent back to search to re-discover the
|
|
47
|
-
// current URI instead of dead-ending (issue #153).
|
|
48
|
-
throw new Error(`Resource not found: ${uriStr}. The URI may be stale (doc URIs change across data refreshes). Call search with keywords from the title to get the current URI.`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return { contents: [resource] };
|
|
28
|
+
return { contents: [await readIndexedResource(uri.toString())] };
|
|
52
29
|
}
|
|
53
30
|
|
|
54
31
|
server.registerResource(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
function errorResult(text) {
|
|
4
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function registerDocumentTools({ server, readIndexedResource }) {
|
|
8
|
+
server.registerTool(
|
|
9
|
+
"read_document",
|
|
10
|
+
{
|
|
11
|
+
title: "Read Document",
|
|
12
|
+
description: [
|
|
13
|
+
"Read the full text of a Dynamsoft documentation page by its doc:// URI.",
|
|
14
|
+
"",
|
|
15
|
+
"WHEN TO USE:",
|
|
16
|
+
"- After search returns doc:// results: call read_document with the exact uri from a resource_link or the plain URI list before answering.",
|
|
17
|
+
"- When you need the actual article body (API details, parameters, code snippets) instead of the search summary.",
|
|
18
|
+
"",
|
|
19
|
+
"WHEN NOT TO USE:",
|
|
20
|
+
"- For sample:// URIs, use get_sample_files with resource_uri instead (complete sample projects).",
|
|
21
|
+
"- Do not construct or edit doc:// URIs yourself; they are generated by search and may change across data refreshes.",
|
|
22
|
+
"",
|
|
23
|
+
"PARAMETERS:",
|
|
24
|
+
"- uri (required): The exact doc:// URI returned by search (e.g. a resource_link uri).",
|
|
25
|
+
"",
|
|
26
|
+
"RETURNS: The document as markdown text: title, public documentation URL (cite it in answers), and the full article body.",
|
|
27
|
+
"On failure (stale or missing URI, unsupported version, unsupported scheme) returns isError with guidance; call search again to get a current URI.",
|
|
28
|
+
"",
|
|
29
|
+
"RELATED TOOLS: search (find doc:// URIs), get_sample_files (read sample:// projects), get_quickstart (starter code)."
|
|
30
|
+
].join("\n"),
|
|
31
|
+
inputSchema: {
|
|
32
|
+
uri: z.string().trim().min(1, "uri is required.").describe("Exact doc:// URI returned by search.")
|
|
33
|
+
},
|
|
34
|
+
annotations: {
|
|
35
|
+
readOnlyHint: true,
|
|
36
|
+
destructiveHint: false,
|
|
37
|
+
idempotentHint: true,
|
|
38
|
+
openWorldHint: false
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
async ({ uri }) => {
|
|
42
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(uri)?.[1]?.toLowerCase();
|
|
43
|
+
if (scheme === "sample") {
|
|
44
|
+
return errorResult(`read_document only reads doc:// URIs. For sample:// URIs, call get_sample_files with resource_uri="${uri}".`);
|
|
45
|
+
}
|
|
46
|
+
if (scheme !== "doc") {
|
|
47
|
+
return errorResult(`Unsupported URI: "${uri}". read_document accepts only doc:// URIs returned by search.`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let resource;
|
|
51
|
+
try {
|
|
52
|
+
resource = await readIndexedResource(uri);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
return errorResult(`Failed to read document: ${message}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const text = typeof resource.text === "string" ? resource.text : "";
|
|
59
|
+
if (!text.trim()) {
|
|
60
|
+
return errorResult(`Document ${uri} has no readable text content. Call search again to find an alternative document.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { content: [{ type: "text", text }] };
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -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,
|
|
@@ -118,9 +117,9 @@ export function registerIndexTools({
|
|
|
118
117
|
"- type: 'doc', 'sample', 'index', 'policy', or 'any' (default). Use 'sample' to restrict to sample results.",
|
|
119
118
|
"- limit: 1-10 (default 5). Max number of results.",
|
|
120
119
|
"",
|
|
121
|
-
"RETURNS: An MCP response whose content array includes a leading text summary item followed by zero or more resource_link items with URIs.
|
|
120
|
+
"RETURNS: An MCP response whose content array includes a leading text summary item followed by zero or more resource_link items with URIs. For doc:// URIs, call read_document with the exact returned uri to get the full document text before answering. For sample:// URIs, call get_sample_files with resource_uri to get the full project files.",
|
|
122
121
|
"",
|
|
123
|
-
"RELATED TOOLS: get_index (discover products first), list_samples (browse all samples),
|
|
122
|
+
"RELATED TOOLS: get_index (discover products first), list_samples (browse all samples), read_document (read a doc:// result), get_sample_files (retrieve full sample project files)."
|
|
124
123
|
].join("\n"),
|
|
125
124
|
inputSchema: {
|
|
126
125
|
query: z.string().trim().min(1, "Query is required.").describe("Keywords to search across docs and samples."),
|
|
@@ -199,7 +198,7 @@ export function registerIndexTools({
|
|
|
199
198
|
const content = [
|
|
200
199
|
{
|
|
201
200
|
type: "text",
|
|
202
|
-
text: `Found ${selected.length} exact match(es) for "${query}".
|
|
201
|
+
text: `Found ${selected.length} exact match(es) for "${query}". Call get_sample_files with the exact resource_uri to get the full sample project.`
|
|
203
202
|
}
|
|
204
203
|
];
|
|
205
204
|
|
|
@@ -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",
|
|
@@ -325,9 +315,10 @@ export function registerIndexTools({
|
|
|
325
315
|
// Capture Vision / DCV is the umbrella framework, not a selectable public
|
|
326
316
|
// product — steer the agent to the right offering (issue #150).
|
|
327
317
|
const dcvUmbrella = /\b(capture[\s-]?vision|dcv)\b/i.test(query) && !normalizedProduct;
|
|
318
|
+
const readGuidance = "For doc:// results, call read_document with the exact uri to get the document text; for sample:// results, call get_sample_files with resource_uri.";
|
|
328
319
|
const leadText = dcvUmbrella
|
|
329
|
-
? `"Capture Vision" (DCV) is Dynamsoft's umbrella framework — pick the public product for your task: DBR (barcodes), MRZ (passports/IDs), MDS (document capture), DWT (scanner hardware), DDV (document viewer). Results below span the closest matches.\nFound ${topResults.length} result(s) for "${query}"
|
|
330
|
-
: `Found ${topResults.length} result(s) for "${query}".
|
|
320
|
+
? `"Capture Vision" (DCV) is Dynamsoft's umbrella framework — pick the public product for your task: DBR (barcodes), MRZ (passports/IDs), MDS (document capture), DWT (scanner hardware), DDV (document viewer). Results below span the closest matches.\nFound ${topResults.length} result(s) for "${query}". ${readGuidance}`
|
|
321
|
+
: `Found ${topResults.length} result(s) for "${query}". ${readGuidance}`;
|
|
331
322
|
const content = [
|
|
332
323
|
{
|
|
333
324
|
type: "text",
|
|
@@ -356,7 +347,7 @@ export function registerIndexTools({
|
|
|
356
347
|
|
|
357
348
|
const plainLines = topResults.map((entry, index) => {
|
|
358
349
|
const sampleId = entry.type === "sample" ? getSampleIdFromUri(entry.uri) : "";
|
|
359
|
-
const action = entry.type === "sample" ? "get_sample_files resource_uri" : "
|
|
350
|
+
const action = entry.type === "sample" ? "get_sample_files resource_uri" : "read_document uri";
|
|
360
351
|
const sampleNote = sampleId ? ` sample_id=${sampleId}` : "";
|
|
361
352
|
const scoreNote = formatScoreNote(entry);
|
|
362
353
|
return `- ${index + 1}. ${entry.uri}${sampleNote}${scoreNote} (${action})`;
|
|
@@ -83,7 +83,7 @@ export function registerProjectTools({
|
|
|
83
83
|
"",
|
|
84
84
|
"WHEN NOT TO USE:",
|
|
85
85
|
"- If you do not have a sample_id or resource_uri yet, call list_samples or search first to discover one.",
|
|
86
|
-
"- For doc:// URIs, use
|
|
86
|
+
"- For doc:// URIs, use read_document instead (this tool only handles sample:// URIs).",
|
|
87
87
|
"- If the user just wants a quick code snippet, use get_quickstart instead.",
|
|
88
88
|
"",
|
|
89
89
|
"PARAMETERS:",
|
|
@@ -137,7 +137,7 @@ export function registerProjectTools({
|
|
|
137
137
|
isError: true,
|
|
138
138
|
content: [{
|
|
139
139
|
type: "text",
|
|
140
|
-
text: "resource_uri must use the sample:// scheme. For doc:// URIs,
|
|
140
|
+
text: "resource_uri must use the sample:// scheme. For doc:// URIs, call read_document with the exact uri instead."
|
|
141
141
|
}]
|
|
142
142
|
};
|
|
143
143
|
}
|
|
@@ -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
|
}
|