simple-dynamsoft-mcp 6.3.0 → 7.0.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 +35 -9
- package/README.md +156 -497
- package/package.json +13 -7
- package/scripts/prebuild-rag-index.mjs +1 -1
- package/scripts/run-gemini-tests.mjs +1 -1
- package/scripts/sync-submodules.mjs +1 -1
- package/scripts/verify-doc-resources.mjs +79 -0
- package/src/data/bootstrap.js +475 -0
- package/src/data/download-utils.js +99 -0
- package/src/data/hydration-mode.js +15 -0
- package/src/data/hydration-policy.js +39 -0
- package/src/data/repo-map.js +149 -0
- package/src/{data-root.js → data/root.js} +1 -1
- package/src/{submodule-sync.js → data/submodule-sync.js} +1 -1
- package/src/index.js +49 -1499
- package/src/observability/logging.js +51 -0
- package/src/rag/config.js +96 -0
- package/src/rag/index.js +266 -0
- package/src/rag/lexical-provider.js +170 -0
- package/src/rag/logger.js +46 -0
- package/src/rag/profile-config.js +48 -0
- package/src/rag/providers.js +585 -0
- package/src/rag/search-utils.js +166 -0
- package/src/rag/vector-cache.js +323 -0
- package/src/server/create-server.js +168 -0
- package/src/server/helpers/server-helpers.js +33 -0
- package/src/{resource-index → server/resource-index}/paths.js +2 -2
- package/src/{resource-index → server/resource-index}/samples.js +9 -1
- package/src/{resource-index.js → server/resource-index.js} +158 -93
- package/src/server/resources/register-resources.js +56 -0
- package/src/server/runtime-config.js +66 -0
- package/src/server/tools/register-index-tools.js +130 -0
- package/src/server/tools/register-project-tools.js +305 -0
- package/src/server/tools/register-quickstart-tools.js +572 -0
- package/src/server/tools/register-sample-tools.js +333 -0
- package/src/server/tools/register-version-tools.js +136 -0
- package/src/server/transports/http.js +84 -0
- package/src/server/transports/stdio.js +12 -0
- package/src/data-bootstrap.js +0 -255
- package/src/rag.js +0 -1203
- /package/src/{gemini-retry.js → rag/gemini-retry.js} +0 -0
- /package/src/{normalizers.js → server/normalizers.js} +0 -0
- /package/src/{resource-index → server/resource-index}/builders.js +0 -0
- /package/src/{resource-index → server/resource-index}/config.js +0 -0
- /package/src/{resource-index → server/resource-index}/docs-loader.js +0 -0
- /package/src/{resource-index → server/resource-index}/uri.js +0 -0
- /package/src/{resource-index → server/resource-index}/version-policy.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simple-dynamsoft-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.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": {
|
|
@@ -23,14 +23,20 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"start": "node src/index.js",
|
|
26
|
-
"test": "npm run test:
|
|
27
|
-
"test:unit": "node --test test/unit/gemini-retry.test.js",
|
|
28
|
-
"test:
|
|
29
|
-
"test:
|
|
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/create-server.test.js test/unit/server-helpers.test.js",
|
|
28
|
+
"test:lite": "npm run test:stdio && npm run test:http && npm run test:package",
|
|
29
|
+
"test:fuse": "npm run test:lite",
|
|
30
|
+
"test:local": "node --test test/integration/stdio.test.js test/integration/http.test.js",
|
|
31
|
+
"test:lexical": "node --test test/integration/stdio.test.js test/integration/http.test.js",
|
|
30
32
|
"test:gemini": "node scripts/run-gemini-tests.mjs",
|
|
31
33
|
"test:stdio": "node --test test/integration/stdio.test.js",
|
|
32
|
-
"test:http": "node --test test/integration/http
|
|
34
|
+
"test:http": "node --test test/integration/http.test.js",
|
|
33
35
|
"test:package": "node --test test/integration/package-runtime.test.js",
|
|
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",
|
|
38
|
+
"test:regression:integration": "node --test test/integration/package-runtime.test.js test/integration/lazy-hydration.test.js",
|
|
39
|
+
"test:regression": "npm run test:regression:unit && npm run test:regression:integration",
|
|
34
40
|
"data:bootstrap": "git submodule sync --recursive && git submodule update --init --recursive --jobs 1",
|
|
35
41
|
"data:sync": "node scripts/sync-submodules.mjs",
|
|
36
42
|
"data:status": "git submodule status --recursive",
|
|
@@ -40,6 +46,7 @@
|
|
|
40
46
|
"data:verify-versions:strict": "node scripts/update-sdk-versions.mjs --check --strict",
|
|
41
47
|
"data:lock": "node scripts/update-data-lock.mjs",
|
|
42
48
|
"data:verify-lock": "node scripts/verify-data-lock.mjs",
|
|
49
|
+
"data:verify-docs": "node scripts/verify-doc-resources.mjs",
|
|
43
50
|
"rag:prebuild": "node scripts/prebuild-rag-index.mjs"
|
|
44
51
|
},
|
|
45
52
|
"keywords": [
|
|
@@ -68,7 +75,6 @@
|
|
|
68
75
|
"zod": "~3.24.0"
|
|
69
76
|
},
|
|
70
77
|
"devDependencies": {
|
|
71
|
-
"supergateway": "^3.4.3",
|
|
72
78
|
"supertest": "^7.1.3"
|
|
73
79
|
}
|
|
74
80
|
}
|
|
@@ -23,7 +23,7 @@ function ensureEnvDefaults() {
|
|
|
23
23
|
ensureEnvDefaults();
|
|
24
24
|
|
|
25
25
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
26
|
-
const { prewarmRagIndex, ragConfig } = await import("../src/rag.js");
|
|
26
|
+
const { prewarmRagIndex, ragConfig } = await import("../src/rag/index.js");
|
|
27
27
|
|
|
28
28
|
await prewarmRagIndex();
|
|
29
29
|
|
|
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
|
|
4
4
|
const child = spawn(
|
|
5
5
|
process.execPath,
|
|
6
|
-
["--test", "test/integration/stdio.test.js", "test/integration/http
|
|
6
|
+
["--test", "test/integration/stdio.test.js", "test/integration/http.test.js"],
|
|
7
7
|
{
|
|
8
8
|
stdio: "inherit",
|
|
9
9
|
env: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { maybeSyncSubmodulesOnStart } from "../src/submodule-sync.js";
|
|
2
|
+
import { maybeSyncSubmodulesOnStart } from "../src/data/submodule-sync.js";
|
|
3
3
|
|
|
4
4
|
process.env.DATA_SYNC_ON_START = "true";
|
|
5
5
|
console.log("[data-sync] cli wrapper enabled DATA_SYNC_ON_START=true");
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resourceIndex, readResourceContent } from "../src/server/resource-index.js";
|
|
4
|
+
|
|
5
|
+
function parsePositiveInt(value, fallback) {
|
|
6
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
7
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
8
|
+
return parsed;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
const concurrency = parsePositiveInt(process.env.DOC_VERIFY_CONCURRENCY, 8);
|
|
13
|
+
const docs = resourceIndex.filter((entry) => entry.type === "doc");
|
|
14
|
+
const total = docs.length;
|
|
15
|
+
|
|
16
|
+
console.log(`[doc-verify] start total_docs=${total} concurrency=${concurrency}`);
|
|
17
|
+
|
|
18
|
+
if (total === 0) {
|
|
19
|
+
console.log("[doc-verify] no docs found; skipping");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let index = 0;
|
|
24
|
+
let checked = 0;
|
|
25
|
+
const failures = [];
|
|
26
|
+
const workers = [];
|
|
27
|
+
|
|
28
|
+
const runOne = async () => {
|
|
29
|
+
while (true) {
|
|
30
|
+
const current = index;
|
|
31
|
+
index += 1;
|
|
32
|
+
if (current >= total) return;
|
|
33
|
+
|
|
34
|
+
const entry = docs[current];
|
|
35
|
+
try {
|
|
36
|
+
const content = await readResourceContent(entry.uri);
|
|
37
|
+
if (!content) {
|
|
38
|
+
throw new Error("readResourceContent returned null");
|
|
39
|
+
}
|
|
40
|
+
const hasText = typeof content.text === "string" && content.text.length > 0;
|
|
41
|
+
const hasBlob = typeof content.blob === "string" && content.blob.length > 0;
|
|
42
|
+
if (!hasText && !hasBlob) {
|
|
43
|
+
throw new Error("resource content is empty");
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
failures.push({
|
|
47
|
+
uri: entry.uri,
|
|
48
|
+
error: error?.message || String(error)
|
|
49
|
+
});
|
|
50
|
+
} finally {
|
|
51
|
+
checked += 1;
|
|
52
|
+
if (checked % 250 === 0 || checked === total) {
|
|
53
|
+
console.log(`[doc-verify] progress checked=${checked}/${total} failures=${failures.length}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < Math.min(concurrency, total); i += 1) {
|
|
60
|
+
workers.push(runOne());
|
|
61
|
+
}
|
|
62
|
+
await Promise.all(workers);
|
|
63
|
+
|
|
64
|
+
if (failures.length > 0) {
|
|
65
|
+
console.error(`[doc-verify] failed count=${failures.length}`);
|
|
66
|
+
for (const failure of failures.slice(0, 20)) {
|
|
67
|
+
console.error(`[doc-verify] error uri=${failure.uri} message=${failure.error}`);
|
|
68
|
+
}
|
|
69
|
+
if (failures.length > 20) {
|
|
70
|
+
console.error(`[doc-verify] ... truncated ${failures.length - 20} additional failures`);
|
|
71
|
+
}
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(`[doc-verify] success checked=${checked}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await main();
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import extractZip from "extract-zip";
|
|
14
|
+
import { bundledDataRoot } from "./root.js";
|
|
15
|
+
import { normalizeHydrationScopes } from "./hydration-policy.js";
|
|
16
|
+
import { resolveRepoPathsForScopes } from "./repo-map.js";
|
|
17
|
+
import { resolveHydrationMode } from "./hydration-mode.js";
|
|
18
|
+
import {
|
|
19
|
+
shouldRetryDownloadError,
|
|
20
|
+
withRetry,
|
|
21
|
+
replaceDirectoryWithRollback,
|
|
22
|
+
buildHydrationFailureMessage
|
|
23
|
+
} from "./download-utils.js";
|
|
24
|
+
import { latencyBucket, logEvent } from "../observability/logging.js";
|
|
25
|
+
|
|
26
|
+
const manifestPath = join(bundledDataRoot, "metadata", "data-manifest.json");
|
|
27
|
+
const sdkRegistryPath = join(bundledDataRoot, "metadata", "dynamsoft_sdks.json");
|
|
28
|
+
|
|
29
|
+
function logData(eventOrMessage, fields = {}, options = {}) {
|
|
30
|
+
if (typeof fields !== "object" || Array.isArray(fields)) {
|
|
31
|
+
logEvent("data", "detail", { message: String(eventOrMessage || "") }, options);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (fields && Object.keys(fields).length > 0) {
|
|
36
|
+
logEvent("data", eventOrMessage, fields, options);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const message = String(eventOrMessage || "").trim();
|
|
41
|
+
if (!message) return;
|
|
42
|
+
logEvent("data", "detail", { message }, { ...options, level: options.level || "debug" });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readBoolEnv(key, fallback = false) {
|
|
46
|
+
const value = process.env[key];
|
|
47
|
+
if (value === undefined || value === "") return fallback;
|
|
48
|
+
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readIntEnv(key, fallback) {
|
|
52
|
+
const value = process.env[key];
|
|
53
|
+
if (value === undefined || value === "") return fallback;
|
|
54
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
55
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readRetryConfig() {
|
|
59
|
+
return {
|
|
60
|
+
maxAttempts: Math.max(1, readIntEnv("MCP_DATA_DOWNLOAD_RETRY_MAX_ATTEMPTS", 3)),
|
|
61
|
+
baseDelayMs: Math.max(0, readIntEnv("MCP_DATA_DOWNLOAD_RETRY_BASE_DELAY_MS", 500)),
|
|
62
|
+
maxDelayMs: Math.max(1, readIntEnv("MCP_DATA_DOWNLOAD_RETRY_MAX_DELAY_MS", 5000))
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readStringEnv(key, fallback = "") {
|
|
67
|
+
const value = process.env[key];
|
|
68
|
+
if (value === undefined || value === "") return fallback;
|
|
69
|
+
return String(value).trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readManifest(rootDir = bundledDataRoot) {
|
|
73
|
+
const path = join(rootDir, "metadata", "data-manifest.json");
|
|
74
|
+
if (!existsSync(path)) return null;
|
|
75
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
76
|
+
if (!parsed || !Array.isArray(parsed.repos)) return null;
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isDirectoryReady(path) {
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(path)) return false;
|
|
83
|
+
const entries = readdirSync(path, { withFileTypes: true });
|
|
84
|
+
// A non-initialized submodule usually has only a `.git` marker entry.
|
|
85
|
+
// Treat that as not ready so runtime bootstrap can download real content.
|
|
86
|
+
return entries.some((entry) => entry.name !== ".git");
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isDataRootReady(rootDir) {
|
|
93
|
+
const registryPath = join(rootDir, "metadata", "dynamsoft_sdks.json");
|
|
94
|
+
if (!existsSync(registryPath)) return false;
|
|
95
|
+
|
|
96
|
+
const manifest = readManifest(rootDir) || readManifest(bundledDataRoot);
|
|
97
|
+
if (!manifest) return false;
|
|
98
|
+
|
|
99
|
+
for (const repo of manifest.repos) {
|
|
100
|
+
const target = join(rootDir, repo.path);
|
|
101
|
+
if (!isDirectoryReady(target)) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getManifestSignature(manifest) {
|
|
107
|
+
const payload = manifest.repos.map((repo) => `${repo.path}@${repo.commit}`).join("|");
|
|
108
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseGithubSlug(repo) {
|
|
112
|
+
if (repo.owner && repo.name) {
|
|
113
|
+
return { owner: repo.owner, name: repo.name };
|
|
114
|
+
}
|
|
115
|
+
const url = String(repo.url || "");
|
|
116
|
+
const httpsMatch = url.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/i);
|
|
117
|
+
if (!httpsMatch) return null;
|
|
118
|
+
return { owner: httpsMatch[1], name: httpsMatch[2] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function downloadFile(url, outputPath, timeoutMs) {
|
|
122
|
+
const retryConfig = readRetryConfig();
|
|
123
|
+
await withRetry(
|
|
124
|
+
async () => {
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new Error(`HTTP ${response.status} for ${url}`);
|
|
132
|
+
}
|
|
133
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
134
|
+
writeFileSync(outputPath, Buffer.from(arrayBuffer));
|
|
135
|
+
const elapsedMs = Date.now() - startedAt;
|
|
136
|
+
logData("download_file", {
|
|
137
|
+
file: basename(outputPath),
|
|
138
|
+
size_bytes: arrayBuffer.byteLength,
|
|
139
|
+
latency_ms: elapsedMs,
|
|
140
|
+
latency_bucket: latencyBucket(elapsedMs)
|
|
141
|
+
}, { level: "debug" });
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
...retryConfig,
|
|
148
|
+
shouldRetry: shouldRetryDownloadError,
|
|
149
|
+
onRetry: ({ attempt, delayMs, error }) => {
|
|
150
|
+
logData("download_retry", {
|
|
151
|
+
attempt: attempt + 1,
|
|
152
|
+
delay_ms: delayMs,
|
|
153
|
+
reason: error.message
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function copyBundledMetadata(targetRoot) {
|
|
161
|
+
const metadataDir = join(targetRoot, "metadata");
|
|
162
|
+
mkdirSync(metadataDir, { recursive: true });
|
|
163
|
+
writeFileSync(join(metadataDir, "dynamsoft_sdks.json"), readFileSync(sdkRegistryPath, "utf8"));
|
|
164
|
+
writeFileSync(join(metadataDir, "data-manifest.json"), readFileSync(manifestPath, "utf8"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ensureMetadataInitialized(targetRoot, manifest, signature) {
|
|
168
|
+
mkdirSync(targetRoot, { recursive: true });
|
|
169
|
+
copyBundledMetadata(targetRoot);
|
|
170
|
+
if (manifest && signature) {
|
|
171
|
+
writeFileSync(join(targetRoot, ".manifest-signature"), signature);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readHydratedReposState(rootDir) {
|
|
176
|
+
const path = join(rootDir, ".hydrated-repos.json");
|
|
177
|
+
if (!existsSync(path)) return {};
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
180
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
181
|
+
return parsed;
|
|
182
|
+
} catch {
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeHydratedReposState(rootDir, state) {
|
|
188
|
+
const path = join(rootDir, ".hydrated-repos.json");
|
|
189
|
+
writeFileSync(path, JSON.stringify(state, null, 2));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function hydrateRepoInPlace({ repo, targetRoot, tempZipRoot, timeoutMs }) {
|
|
193
|
+
const slug = parseGithubSlug(repo);
|
|
194
|
+
if (!slug) {
|
|
195
|
+
throw new Error(`Unable to parse GitHub slug for ${repo.path}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const archiveUrl = repo.archiveUrl || `https://codeload.github.com/${slug.owner}/${slug.name}/zip/${repo.commit}`;
|
|
199
|
+
const zipPath = join(tempZipRoot, `${repo.path.replace(/[\\/]/g, "_")}.zip`);
|
|
200
|
+
const extractRoot = join(tempZipRoot, `${repo.path.replace(/[\\/]/g, "_")}-extract`);
|
|
201
|
+
const targetPath = join(targetRoot, repo.path);
|
|
202
|
+
const targetParent = dirname(targetPath);
|
|
203
|
+
|
|
204
|
+
logData("repo_hydrate_start", {
|
|
205
|
+
repo_path: repo.path,
|
|
206
|
+
commit: String(repo.commit || "").slice(0, 12),
|
|
207
|
+
host: "codeload.github.com"
|
|
208
|
+
}, { level: "debug" });
|
|
209
|
+
mkdirSync(targetParent, { recursive: true });
|
|
210
|
+
await downloadFile(archiveUrl, zipPath, timeoutMs);
|
|
211
|
+
await extractZip(zipPath, { dir: extractRoot });
|
|
212
|
+
|
|
213
|
+
let extractedFolder = join(extractRoot, `${slug.name}-${repo.commit}`);
|
|
214
|
+
if (!existsSync(extractedFolder)) {
|
|
215
|
+
const children = readdirSync(extractRoot, { withFileTypes: true })
|
|
216
|
+
.filter((entry) => entry.isDirectory())
|
|
217
|
+
.map((entry) => join(extractRoot, entry.name));
|
|
218
|
+
if (children.length === 1) {
|
|
219
|
+
extractedFolder = children[0];
|
|
220
|
+
} else {
|
|
221
|
+
const fallbackName = basename(extractRoot);
|
|
222
|
+
throw new Error(`Archive layout is not recognized for ${repo.path} (${fallbackName})`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
replaceDirectoryWithRollback(targetPath, extractedFolder);
|
|
227
|
+
logData("repo_hydrate_done", { repo_path: repo.path });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function populateFromManifest(targetRoot, manifest, timeoutMs) {
|
|
231
|
+
const tempZipRoot = join(tmpdir(), `simple-dynamsoft-mcp-zips-${Date.now()}`);
|
|
232
|
+
mkdirSync(tempZipRoot, { recursive: true });
|
|
233
|
+
logData(`populate start repos=${manifest.repos.length} timeout_ms=${timeoutMs} staging=${targetRoot}`);
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
for (const repo of manifest.repos) {
|
|
237
|
+
await hydrateRepoInPlace({
|
|
238
|
+
repo,
|
|
239
|
+
targetRoot,
|
|
240
|
+
tempZipRoot,
|
|
241
|
+
timeoutMs
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
logData(`populate done repos=${manifest.repos.length}`);
|
|
245
|
+
} finally {
|
|
246
|
+
rmSync(tempZipRoot, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function finalizeCacheRoot(cacheRoot, stagingRoot, signature) {
|
|
251
|
+
const parent = dirname(cacheRoot);
|
|
252
|
+
mkdirSync(parent, { recursive: true });
|
|
253
|
+
|
|
254
|
+
if (existsSync(cacheRoot)) {
|
|
255
|
+
rmSync(cacheRoot, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
renameSync(stagingRoot, cacheRoot);
|
|
259
|
+
writeFileSync(join(cacheRoot, ".manifest-signature"), signature);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function ensureDownloadedData(cacheRoot) {
|
|
263
|
+
const manifest = readManifest(bundledDataRoot);
|
|
264
|
+
if (!manifest) {
|
|
265
|
+
throw new Error(`Missing manifest at ${manifestPath}. Run npm run data:lock.`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const signature = getManifestSignature(manifest);
|
|
269
|
+
const signaturePath = join(cacheRoot, ".manifest-signature");
|
|
270
|
+
const refresh = readBoolEnv("MCP_DATA_REFRESH_ON_START", false);
|
|
271
|
+
logData(
|
|
272
|
+
`download plan repos=${manifest.repos.length} cache_root=${cacheRoot} refresh=${refresh} signature=${signature.slice(0, 12)}`
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
if (!refresh && existsSync(signaturePath)) {
|
|
276
|
+
const existingSignature = readFileSync(signaturePath, "utf8").trim();
|
|
277
|
+
if (existingSignature === signature && isDataRootReady(cacheRoot)) {
|
|
278
|
+
logData("cache_hit", {
|
|
279
|
+
signature: signature.slice(0, 12),
|
|
280
|
+
root: cacheRoot,
|
|
281
|
+
cache_source: "cache"
|
|
282
|
+
});
|
|
283
|
+
return { downloaded: false };
|
|
284
|
+
}
|
|
285
|
+
logData(`cache refresh required reason=signature_or_readiness_mismatch root=${cacheRoot}`);
|
|
286
|
+
} else if (refresh) {
|
|
287
|
+
logData(`cache refresh forced by MCP_DATA_REFRESH_ON_START root=${cacheRoot}`);
|
|
288
|
+
} else {
|
|
289
|
+
logData(`cache miss signature_file=${signaturePath}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const timeoutMs = readIntEnv("MCP_DATA_DOWNLOAD_TIMEOUT_MS", 180000);
|
|
293
|
+
const stagingRoot = join(dirname(cacheRoot), `.tmp-data-${Date.now()}`);
|
|
294
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
295
|
+
mkdirSync(stagingRoot, { recursive: true });
|
|
296
|
+
logData(`download start staging_root=${stagingRoot} timeout_ms=${timeoutMs}`);
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
copyBundledMetadata(stagingRoot);
|
|
300
|
+
await populateFromManifest(stagingRoot, manifest, timeoutMs);
|
|
301
|
+
finalizeCacheRoot(cacheRoot, stagingRoot, signature);
|
|
302
|
+
logData("download_complete", {
|
|
303
|
+
root: cacheRoot,
|
|
304
|
+
repos: manifest.repos.length,
|
|
305
|
+
cache_source: "fresh-download"
|
|
306
|
+
});
|
|
307
|
+
return { downloaded: true };
|
|
308
|
+
} catch (error) {
|
|
309
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
310
|
+
logData("download_failed", { root: cacheRoot, error: error.message }, { level: "error" });
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function ensureLazyMetadataRoot(cacheRoot, manifest, signature) {
|
|
316
|
+
ensureMetadataInitialized(cacheRoot, manifest, signature);
|
|
317
|
+
logData(`lazy metadata ready root=${cacheRoot}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function ensureDownloadedRepos(cacheRoot, repoPaths) {
|
|
321
|
+
const manifest = readManifest(bundledDataRoot);
|
|
322
|
+
if (!manifest) {
|
|
323
|
+
throw new Error(`Missing manifest at ${manifestPath}. Run npm run data:lock.`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const wanted = new Set(repoPaths || []);
|
|
327
|
+
if (wanted.size === 0) return { hydrated: [] };
|
|
328
|
+
|
|
329
|
+
const signature = getManifestSignature(manifest);
|
|
330
|
+
ensureMetadataInitialized(cacheRoot, manifest, signature);
|
|
331
|
+
|
|
332
|
+
const timeoutMs = readIntEnv("MCP_DATA_DOWNLOAD_TIMEOUT_MS", 180000);
|
|
333
|
+
const state = readHydratedReposState(cacheRoot);
|
|
334
|
+
const tempZipRoot = join(tmpdir(), `simple-dynamsoft-mcp-zips-lazy-${Date.now()}`);
|
|
335
|
+
mkdirSync(tempZipRoot, { recursive: true });
|
|
336
|
+
|
|
337
|
+
const hydrated = [];
|
|
338
|
+
try {
|
|
339
|
+
for (const repo of manifest.repos) {
|
|
340
|
+
if (!wanted.has(repo.path)) continue;
|
|
341
|
+
|
|
342
|
+
const targetPath = join(cacheRoot, repo.path);
|
|
343
|
+
const knownCommit = String(state[repo.path] || "");
|
|
344
|
+
const expectedCommit = String(repo.commit || "");
|
|
345
|
+
if (isDirectoryReady(targetPath) && knownCommit === expectedCommit) {
|
|
346
|
+
logData("hydrate_cache_hit", { repo_path: repo.path }, { level: "debug" });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await hydrateRepoInPlace({
|
|
351
|
+
repo,
|
|
352
|
+
targetRoot: cacheRoot,
|
|
353
|
+
tempZipRoot,
|
|
354
|
+
timeoutMs
|
|
355
|
+
});
|
|
356
|
+
state[repo.path] = expectedCommit;
|
|
357
|
+
hydrated.push(repo.path);
|
|
358
|
+
}
|
|
359
|
+
writeHydratedReposState(cacheRoot, state);
|
|
360
|
+
return { hydrated };
|
|
361
|
+
} finally {
|
|
362
|
+
rmSync(tempZipRoot, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function ensureDataReady() {
|
|
367
|
+
const explicitRoot = process.env.MCP_DATA_DIR ? resolve(process.env.MCP_DATA_DIR) : "";
|
|
368
|
+
logData(`resolve start explicit_root=${explicitRoot || "(none)"} bundled_root=${bundledDataRoot}`);
|
|
369
|
+
if (explicitRoot) {
|
|
370
|
+
if (!isDataRootReady(explicitRoot)) {
|
|
371
|
+
logData(`custom data root invalid root=${explicitRoot}`);
|
|
372
|
+
throw new Error(`MCP_DATA_DIR is set but data is incomplete: ${explicitRoot}`);
|
|
373
|
+
}
|
|
374
|
+
process.env.MCP_RESOLVED_DATA_DIR = explicitRoot;
|
|
375
|
+
process.env.MCP_DATA_RESOLVE_MODE = "custom";
|
|
376
|
+
logData("resolve_done", { mode: "custom", root: explicitRoot, cache_source: "custom" });
|
|
377
|
+
return { dataRoot: explicitRoot, mode: "custom", downloaded: false };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (isDataRootReady(bundledDataRoot)) {
|
|
381
|
+
process.env.MCP_RESOLVED_DATA_DIR = bundledDataRoot;
|
|
382
|
+
process.env.MCP_DATA_RESOLVE_MODE = "bundled";
|
|
383
|
+
logData("resolve_done", { mode: "bundled", root: bundledDataRoot, cache_source: "bundled" });
|
|
384
|
+
return { dataRoot: bundledDataRoot, mode: "bundled", downloaded: false };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const autoDownload = readBoolEnv("MCP_DATA_AUTO_DOWNLOAD", true);
|
|
388
|
+
logData(`bundled data not ready auto_download=${autoDownload}`);
|
|
389
|
+
if (!autoDownload) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
"Bundled data is not available and MCP_DATA_AUTO_DOWNLOAD is disabled. " +
|
|
392
|
+
"Set MCP_DATA_AUTO_DOWNLOAD=true or provide MCP_DATA_DIR."
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const defaultCacheRoot = join(process.env.LOCALAPPDATA || join(homedir(), ".cache"), "simple-dynamsoft-mcp", "data");
|
|
397
|
+
const cacheRoot = resolve(process.env.MCP_DATA_CACHE_DIR || defaultCacheRoot);
|
|
398
|
+
const hydrationMode = resolveHydrationMode(process.env);
|
|
399
|
+
const manifest = readManifest(bundledDataRoot);
|
|
400
|
+
if (!manifest) {
|
|
401
|
+
throw new Error(`Missing manifest at ${manifestPath}. Run npm run data:lock.`);
|
|
402
|
+
}
|
|
403
|
+
const signature = getManifestSignature(manifest);
|
|
404
|
+
|
|
405
|
+
let result;
|
|
406
|
+
if (hydrationMode === "lazy") {
|
|
407
|
+
ensureLazyMetadataRoot(cacheRoot, manifest, signature);
|
|
408
|
+
result = { downloaded: false };
|
|
409
|
+
} else {
|
|
410
|
+
result = await ensureDownloadedData(cacheRoot);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
process.env.MCP_RESOLVED_DATA_DIR = cacheRoot;
|
|
414
|
+
process.env.MCP_DATA_RESOLVE_MODE = hydrationMode === "lazy" ? "downloaded-lazy" : "downloaded";
|
|
415
|
+
logData("resolve_done", {
|
|
416
|
+
mode: process.env.MCP_DATA_RESOLVE_MODE,
|
|
417
|
+
root: cacheRoot,
|
|
418
|
+
cache_source: result?.downloaded ? "fresh-download" : "cache"
|
|
419
|
+
});
|
|
420
|
+
return {
|
|
421
|
+
dataRoot: cacheRoot,
|
|
422
|
+
mode: process.env.MCP_DATA_RESOLVE_MODE,
|
|
423
|
+
downloaded: Boolean(result?.downloaded)
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function ensureDataScopesHydrated(scopes) {
|
|
428
|
+
const mode = readStringEnv("MCP_DATA_RESOLVE_MODE", "").toLowerCase();
|
|
429
|
+
if (mode !== "downloaded-lazy") {
|
|
430
|
+
return { hydrated: [], mode, skipped: true };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const root = process.env.MCP_RESOLVED_DATA_DIR ? resolve(process.env.MCP_RESOLVED_DATA_DIR) : "";
|
|
434
|
+
if (!root) {
|
|
435
|
+
return { hydrated: [], mode, skipped: true };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const manifest = readManifest(bundledDataRoot);
|
|
439
|
+
const normalizedScopes = normalizeHydrationScopes(scopes);
|
|
440
|
+
const repoPaths = resolveRepoPathsForScopes(normalizedScopes, manifest);
|
|
441
|
+
if (repoPaths.length === 0) {
|
|
442
|
+
return { hydrated: [], mode, skipped: true };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
logData("hydrate_plan", {
|
|
446
|
+
mode: "lazy",
|
|
447
|
+
hydration_scope_count: normalizedScopes.length,
|
|
448
|
+
hydration_scope: normalizedScopes
|
|
449
|
+
.map((scope) => `${scope.product}:${scope.edition || "any"}:${scope.type}`)
|
|
450
|
+
.join(","),
|
|
451
|
+
repos: repoPaths.length
|
|
452
|
+
});
|
|
453
|
+
try {
|
|
454
|
+
const result = await ensureDownloadedRepos(root, repoPaths);
|
|
455
|
+
if (result.hydrated.length > 0) {
|
|
456
|
+
logData("hydrate_done", {
|
|
457
|
+
mode: "lazy",
|
|
458
|
+
repos: result.hydrated.length,
|
|
459
|
+
cache_source: "fresh-download"
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return { hydrated: result.hydrated, mode, skipped: false };
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const scopeSummary = normalizedScopes
|
|
465
|
+
.map((scope) => `product=${scope.product} edition=${scope.edition || "any"} type=${scope.type}`)
|
|
466
|
+
.join("; ");
|
|
467
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
468
|
+
throw new Error(buildHydrationFailureMessage({ reason, scopeSummary }));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export {
|
|
473
|
+
ensureDataReady,
|
|
474
|
+
ensureDataScopesHydrated
|
|
475
|
+
};
|