dsh-eda-mcp 1.1.0 → 1.1.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/CHANGELOG.md +10 -0
- package/README.md +8 -0
- package/README.zh.md +4 -0
- package/lib/client.js +57 -8
- package/lib/index.js +86 -7
- package/package.json +2 -2
- package/src/client/EdaMcpSettingsCard.tsx +41 -0
- package/src/client/locales.ts +10 -0
- package/src/index.ts +11 -0
- package/src/routes.ts +68 -5
- package/test/extension-routes.test.ts +95 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.1 — 2026-10-21
|
|
4
|
+
|
|
5
|
+
### 设置卡片:下载仍是本地那份,但**能校验服务器副本**
|
|
6
|
+
|
|
7
|
+
- 事实澄清(改前就是如此):设置卡片的「下载 JLCEDA 扩展包」是 `<a href="/api/dsh-eda-mcp/extension.zip">`,由插件读**包内自带**的 zip 直接回传,**不经过服务器**。这是有意的:离线也能装、不会被限流、不依赖服务器可用性。
|
|
8
|
+
- 现在响应头带 `x-extension-version`、`x-extension-sha256`、`content-length`,下载到的文件可自行核对。
|
|
9
|
+
- 新增路由 `GET /api/dsh-eda-mcp/extension/verify`:从下载服务器取回同一文件并逐字节比对,返回 `verified: match | mismatch | unverified`(429 会带 `httpStatus` 与"是限流不是不一致"的说明)。卡片上多一个「校验服务器副本」按钮直接显示结果。
|
|
10
|
+
- 新增第 **11** 套单测 `extension-routes`:用真实本地 HTTP 服务模拟三种服务器响应(同字节 / 不同字节 / 429),断言 `match`、`mismatch`、`unverified + 429`,并断言下载响应的 sha256 头与实际发出的字节一致。
|
|
11
|
+
|
|
12
|
+
|
|
3
13
|
## 1.1.0 — 2026-10-21
|
|
4
14
|
|
|
5
15
|
### `/eda/` 下载限流(服务器侧,5 次/分钟/IP)
|
package/README.md
CHANGED
|
@@ -58,6 +58,14 @@ produce the same sha256.
|
|
|
58
58
|
The download server throttles `/eda/` to 5 requests/minute per IP (burst 5, loopback exempt), so
|
|
59
59
|
a `429` shows up as `verified: 'unverified'` with `httpStatus: 429` — that is throttling, not a
|
|
60
60
|
checksum mismatch; wait a minute and retry.
|
|
61
|
+
The settings card serves the **bundled** copy (works offline, never throttled) and stamps
|
|
62
|
+
`x-extension-version` / `x-extension-sha256` on the response; its "Verify server copy" button calls
|
|
63
|
+
`/api/dsh-eda-mcp/extension/verify` and reports match / mismatch / why verification was impossible
|
|
64
|
+
(429 throttling included).
|
|
65
|
+
The settings card serves the **bundled** copy (works offline, never throttled) and stamps
|
|
66
|
+
`x-extension-version` / `x-extension-sha256` on the response; its "Verify server copy" button calls
|
|
67
|
+
`/api/dsh-eda-mcp/extension/verify` and reports match / mismatch / why verification was impossible
|
|
68
|
+
(429 throttling included).
|
|
61
69
|
|
|
62
70
|
### 1. Install into the DSH web profile
|
|
63
71
|
|
package/README.zh.md
CHANGED
|
@@ -61,6 +61,10 @@ pnpm add dsh-eda-mcp # 或用 Web GUI 的插件管理器安装
|
|
|
61
61
|
|
|
62
62
|
下载服务器对 `/eda/` 限流 **5 次/分钟/IP**(burst 5,回环地址豁免):被限流时工具返回 `verified: unverified` 且 `httpStatus: 429` —— 这是限流,不是校验不一致,等一分钟重试即可。
|
|
63
63
|
|
|
64
|
+
设置卡片的下载按钮给的是**插件自带的那份**(离线可用、不被限流),响应头带 `x-extension-version` 与 `x-extension-sha256`;旁边的「校验服务器副本」按钮调用 `/api/dsh-eda-mcp/extension/verify`,逐字节比对后给出 `match / mismatch / 无法校验(含 429 限流原因)`。
|
|
65
|
+
|
|
66
|
+
设置卡片的下载按钮给的是**插件自带的那份**(离线可用、不被限流),响应头带 `x-extension-version` 与 `x-extension-sha256`;旁边的「校验服务器副本」按钮调用 `/api/dsh-eda-mcp/extension/verify`,逐字节比对后直接给出 `match / mismatch / 无法校验(含 429 限流原因)`。
|
|
67
|
+
|
|
64
68
|
### 1. 安装到 DSH Web profile
|
|
65
69
|
|
|
66
70
|
```sh
|
package/lib/client.js
CHANGED
|
@@ -1135,6 +1135,30 @@ function EdaMcpSettingsCard(props) {
|
|
|
1135
1135
|
const [testing, setTesting] = (0, import_react.useState)(false);
|
|
1136
1136
|
const [reconnecting, setReconnecting] = (0, import_react.useState)(false);
|
|
1137
1137
|
const [notice, setNotice] = (0, import_react.useState)();
|
|
1138
|
+
const [verifyingExtension, setVerifyingExtension] = (0, import_react.useState)(false);
|
|
1139
|
+
const [extensionVerdict, setExtensionVerdict] = (0, import_react.useState)();
|
|
1140
|
+
const verifyExtension = async () => {
|
|
1141
|
+
if (verifyingExtension) return;
|
|
1142
|
+
setVerifyingExtension(true);
|
|
1143
|
+
try {
|
|
1144
|
+
const response = await fetch("/api/dsh-eda-mcp/extension/verify");
|
|
1145
|
+
const body = await response.json();
|
|
1146
|
+
if (body.verified === "match") {
|
|
1147
|
+
const short = String(body.local?.sha256 ?? "").slice(0, 16) + "\u2026";
|
|
1148
|
+
setExtensionVerdict({ kind: "ok", text: t("settings.verifyMatch").replace("{hash}", short) });
|
|
1149
|
+
} else if (body.verified === "mismatch") {
|
|
1150
|
+
setExtensionVerdict({ kind: "error", text: t("settings.verifyMismatch").replace("{hash}", String(body.file ?? "")) });
|
|
1151
|
+
} else {
|
|
1152
|
+
const reason = String(body.error ?? "unknown");
|
|
1153
|
+
setExtensionVerdict({ kind: "error", text: t("settings.verifyUnverified").replace("{reason}", reason) });
|
|
1154
|
+
}
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1157
|
+
setExtensionVerdict({ kind: "error", text: t("settings.verifyUnverified").replace("{reason}", reason) });
|
|
1158
|
+
} finally {
|
|
1159
|
+
setVerifyingExtension(false);
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1138
1162
|
(0, import_react.useEffect)(() => {
|
|
1139
1163
|
if (!seeded && state.status === "ready") {
|
|
1140
1164
|
setDraft(state.config ?? {});
|
|
@@ -1292,14 +1316,29 @@ function EdaMcpSettingsCard(props) {
|
|
|
1292
1316
|
}
|
|
1293
1317
|
),
|
|
1294
1318
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { ...hintStyle, margin: "8px 0 0" }, children: t("settings.portLabel") }),
|
|
1295
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1319
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Field, { label: t("settings.downloadExtension"), hint: t("settings.installHint"), children: [
|
|
1320
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1321
|
+
"a",
|
|
1322
|
+
{
|
|
1323
|
+
href: "/api/dsh-eda-mcp/extension.zip",
|
|
1324
|
+
style: { ...secondaryButtonStyle, textDecoration: "none", display: "inline-flex", width: "fit-content" },
|
|
1325
|
+
children: t("settings.downloadExtension")
|
|
1326
|
+
}
|
|
1327
|
+
),
|
|
1328
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1329
|
+
"button",
|
|
1330
|
+
{
|
|
1331
|
+
type: "button",
|
|
1332
|
+
onClick: () => {
|
|
1333
|
+
void verifyExtension();
|
|
1334
|
+
},
|
|
1335
|
+
disabled: verifyingExtension,
|
|
1336
|
+
style: verifyingExtension ? { ...secondaryButtonStyle, ...disabledButtonStyle, marginLeft: 8 } : { ...secondaryButtonStyle, marginLeft: 8 },
|
|
1337
|
+
children: verifyingExtension ? t("settings.verifyBusy") : t("settings.verifyExtension")
|
|
1338
|
+
}
|
|
1339
|
+
),
|
|
1340
|
+
extensionVerdict !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", style: { ...hintStyle, margin: "6px 0 0", color: extensionVerdict.kind === "ok" ? void 0 : "#c0392b" }, children: extensionVerdict.text }) : null
|
|
1341
|
+
] })
|
|
1303
1342
|
] }),
|
|
1304
1343
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("footer", { style: footerStyle, children: [
|
|
1305
1344
|
notice !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: noticeStyle, role: "status", children: notice.text }) : null,
|
|
@@ -1478,6 +1517,11 @@ var zh = {
|
|
|
1478
1517
|
"settings.reconnectFailed": "\u91CD\u8FDE\u5931\u8D25",
|
|
1479
1518
|
"settings.portLabel": "\u6865\u63A5 WebSocket \u7AEF\u53E3\uFF1A39009\uFF08\u56FA\u5B9A\uFF09",
|
|
1480
1519
|
"settings.downloadExtension": "\u4E0B\u8F7D JLCEDA \u6269\u5C55\u5305",
|
|
1520
|
+
"settings.verifyExtension": "\u6821\u9A8C\u670D\u52A1\u5668\u526F\u672C",
|
|
1521
|
+
"settings.verifyBusy": "\u6821\u9A8C\u4E2D\u2026",
|
|
1522
|
+
"settings.verifyMatch": "\u4E00\u81F4\uFF1A\u670D\u52A1\u5668\u4E0A\u7684 zip \u4E0E\u672C\u5730\u8FD9\u4EFD\u9010\u5B57\u8282\u76F8\u540C\uFF08sha256 {hash}\uFF09",
|
|
1523
|
+
"settings.verifyMismatch": "\u4E0D\u4E00\u81F4\uFF1A\u670D\u52A1\u5668\u90A3\u4EFD\u4E0E\u672C\u5730\u4E0D\u540C\uFF0C\u8BF7\u91CD\u65B0\u4E0A\u4F20 {hash}",
|
|
1524
|
+
"settings.verifyUnverified": "\u65E0\u6CD5\u6821\u9A8C\uFF1A{reason}",
|
|
1481
1525
|
"settings.installHint": "\u5728\u5609\u7ACB\u521BEDA\u4E13\u4E1A\u7248\u4E2D\uFF1A\u6269\u5C55 \u2192 \u5BFC\u5165 \u2192 \u9009\u62E9\u4E0B\u8F7D\u7684 zip \u2192 \u5728\u6269\u5C55\u5217\u8868\u5F00\u542F\u672C\u6269\u5C55\u7684\u300C\u5141\u8BB8\u5916\u90E8\u4EA4\u4E92\u300D\u3002"
|
|
1482
1526
|
};
|
|
1483
1527
|
var en = {
|
|
@@ -1510,6 +1554,11 @@ var en = {
|
|
|
1510
1554
|
"settings.reconnectFailed": "Reconnect failed",
|
|
1511
1555
|
"settings.portLabel": "Bridge WebSocket port: 39009 (fixed)",
|
|
1512
1556
|
"settings.downloadExtension": "Download JLCEDA extension zip",
|
|
1557
|
+
"settings.verifyExtension": "Verify server copy",
|
|
1558
|
+
"settings.verifyBusy": "Verifying\u2026",
|
|
1559
|
+
"settings.verifyMatch": "Match: the served zip is byte-identical to the local one (sha256 {hash})",
|
|
1560
|
+
"settings.verifyMismatch": "Mismatch: the served zip differs \u2014 re-upload {hash}",
|
|
1561
|
+
"settings.verifyUnverified": "Could not verify: {reason}",
|
|
1513
1562
|
"settings.installHint": 'In JLCEDA Pro: Extensions \u2192 Import \u2192 select the downloaded zip \u2192 enable "Allow external interactions" for this extension.'
|
|
1514
1563
|
};
|
|
1515
1564
|
|
package/lib/index.js
CHANGED
|
@@ -434,7 +434,7 @@ function createEdaSkillProvider() {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
// src/routes.ts
|
|
437
|
-
import {
|
|
437
|
+
import { readFile } from "node:fs/promises";
|
|
438
438
|
import { basename } from "node:path";
|
|
439
439
|
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
440
440
|
|
|
@@ -475,11 +475,32 @@ function isLoopbackRequest(request) {
|
|
|
475
475
|
}
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// src/bridge/extension-package.ts
|
|
479
|
+
import { createHash } from "node:crypto";
|
|
480
|
+
function sha256Of(bytes) {
|
|
481
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
482
|
+
}
|
|
483
|
+
function extensionZipName(version) {
|
|
484
|
+
return "dsh-eda-bridge-extension-" + version + ".zip";
|
|
485
|
+
}
|
|
486
|
+
function extensionDownloadUrl(base, version) {
|
|
487
|
+
const normalised = base.endsWith("/") ? base : base + "/";
|
|
488
|
+
return new URL(extensionZipName(version), normalised).toString();
|
|
489
|
+
}
|
|
490
|
+
function compareDigest(expected, actual) {
|
|
491
|
+
if (typeof expected !== "string" || typeof actual !== "string") return "unverified";
|
|
492
|
+
const left = expected.trim().toLowerCase();
|
|
493
|
+
const right = actual.trim().toLowerCase();
|
|
494
|
+
if (left === "" || right === "") return "unverified";
|
|
495
|
+
return left === right ? "match" : "mismatch";
|
|
496
|
+
}
|
|
497
|
+
|
|
478
498
|
// src/routes.ts
|
|
479
499
|
var EDA_MCP_API = {
|
|
480
500
|
test: "/api/dsh-eda-mcp/test",
|
|
481
501
|
reconnect: "/api/dsh-eda-mcp/reconnect",
|
|
482
502
|
extensionZip: "/api/dsh-eda-mcp/extension.zip",
|
|
503
|
+
extensionVerify: "/api/dsh-eda-mcp/extension/verify",
|
|
483
504
|
diagnostics: "/api/dsh-eda-mcp/diagnostics",
|
|
484
505
|
selfTest: "/api/dsh-eda-mcp/self-test",
|
|
485
506
|
settingsDescribe: "/api/dsh-eda-mcp/settings/describe",
|
|
@@ -607,20 +628,68 @@ function makeEdaMcpRoutes(deps) {
|
|
|
607
628
|
handler: async (req, res) => {
|
|
608
629
|
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
609
630
|
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
|
|
631
|
+
let bytes;
|
|
632
|
+
try {
|
|
633
|
+
bytes = await readFile(deps.extensionZipPath);
|
|
634
|
+
} catch {
|
|
635
|
+
return json(res, 404, { ok: false, error: "extension zip missing next to the plugin" });
|
|
636
|
+
}
|
|
610
637
|
res.writeHead(200, {
|
|
611
638
|
"content-type": "application/zip",
|
|
612
639
|
// Keep the versioned file name the browser saves it as.
|
|
613
640
|
"content-disposition": 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
|
|
614
|
-
"cache-control": "no-store"
|
|
641
|
+
"cache-control": "no-store",
|
|
642
|
+
"x-extension-version": deps.extensionVersion,
|
|
643
|
+
"x-extension-sha256": sha256Of(bytes),
|
|
644
|
+
"content-length": String(bytes.byteLength)
|
|
615
645
|
});
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
646
|
+
res.end(bytes);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
const extensionVerify = {
|
|
650
|
+
kind: "exact",
|
|
651
|
+
path: EDA_MCP_API.extensionVerify,
|
|
652
|
+
handler: async (req, res) => {
|
|
653
|
+
if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
|
|
654
|
+
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: "forbidden" });
|
|
655
|
+
let localBytes;
|
|
656
|
+
try {
|
|
657
|
+
localBytes = await readFile(deps.extensionZipPath);
|
|
658
|
+
} catch {
|
|
659
|
+
return json(res, 404, { ok: false, error: "extension zip missing next to the plugin" });
|
|
660
|
+
}
|
|
661
|
+
const localSha256 = sha256Of(localBytes);
|
|
662
|
+
const url = extensionDownloadUrl(deps.extensionDownloadBase, deps.extensionVersion);
|
|
663
|
+
const report = {
|
|
664
|
+
version: deps.extensionVersion,
|
|
665
|
+
file: basename(deps.extensionZipPath),
|
|
666
|
+
local: { bytes: localBytes.byteLength, sha256: localSha256 },
|
|
667
|
+
url
|
|
668
|
+
};
|
|
669
|
+
let response;
|
|
670
|
+
try {
|
|
671
|
+
response = await fetch(url, { signal: AbortSignal.timeout(2e4) });
|
|
672
|
+
} catch (error) {
|
|
673
|
+
return json(res, 200, { ...report, verified: "unverified", error: "download failed: " + (error instanceof Error ? error.message : String(error)) });
|
|
674
|
+
}
|
|
675
|
+
if (!response.ok) {
|
|
676
|
+
return json(res, 200, {
|
|
677
|
+
...report,
|
|
678
|
+
verified: "unverified",
|
|
679
|
+
httpStatus: response.status,
|
|
680
|
+
error: response.status === 429 ? "the download server throttled this IP (HTTP 429, 5 downloads/min). Wait about a minute and verify again." : "download failed: HTTP " + response.status
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
const remoteBytes = new Uint8Array(await response.arrayBuffer());
|
|
684
|
+
const remoteSha256 = sha256Of(remoteBytes);
|
|
685
|
+
return json(res, 200, {
|
|
686
|
+
...report,
|
|
687
|
+
remote: { bytes: remoteBytes.byteLength, sha256: remoteSha256 },
|
|
688
|
+
verified: compareDigest(localSha256, remoteSha256)
|
|
619
689
|
});
|
|
620
|
-
stream.pipe(res);
|
|
621
690
|
}
|
|
622
691
|
};
|
|
623
|
-
return [test, reconnect, extensionZip, diagnostics, selfTest];
|
|
692
|
+
return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest];
|
|
624
693
|
}
|
|
625
694
|
function makeEdaMcpSettingsRoutes(settings) {
|
|
626
695
|
const ns = "eda-mcp";
|
|
@@ -951,6 +1020,14 @@ function installSettingsSection(ctx, scoped, ns, schema, entry, hooks) {
|
|
|
951
1020
|
}
|
|
952
1021
|
var inject = ["webServer", "systemPrompt"];
|
|
953
1022
|
var BRIDGE_URL = new URL("../lib/bridge.mjs", import.meta.url);
|
|
1023
|
+
function resolveExtensionVersion() {
|
|
1024
|
+
try {
|
|
1025
|
+
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../jlc-extension/extension.json", import.meta.url)), "utf8"));
|
|
1026
|
+
return typeof manifest.version === "string" ? manifest.version : "";
|
|
1027
|
+
} catch {
|
|
1028
|
+
return "";
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
954
1031
|
function resolveExtensionZipPath() {
|
|
955
1032
|
const plain = fileURLToPath(new URL("../dsh-eda-bridge-extension.zip", import.meta.url));
|
|
956
1033
|
try {
|
|
@@ -1054,6 +1131,8 @@ function apply(ctx, config) {
|
|
|
1054
1131
|
const disposers = makeEdaMcpRoutes({
|
|
1055
1132
|
getConfig: () => current(),
|
|
1056
1133
|
extensionZipPath: resolveExtensionZipPath(),
|
|
1134
|
+
extensionVersion: resolveExtensionVersion(),
|
|
1135
|
+
extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? "http://159.75.130.207/eda/",
|
|
1057
1136
|
reconnect: remountClient,
|
|
1058
1137
|
bridgeLaunch: describeBridgeLaunch,
|
|
1059
1138
|
// Surface the skill registration so "did it register?" is answerable from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-eda-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "DeepSeek Harness (DSH) plugin — connects DSH to JLCEDA Pro (EasyEDA Pro) via a local MCP bridge and draws schematics through the JLCEDA extension API.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"scripts": {
|
|
73
73
|
"build": "node build.mjs",
|
|
74
74
|
"typecheck": "tsc --noEmit",
|
|
75
|
-
"test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/extension-package.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
|
|
75
|
+
"test": "node --experimental-strip-types test/node-runtime.test.ts && node --experimental-strip-types test/config-values.test.ts && node --experimental-strip-types test/design-rules.test.ts && node --experimental-strip-types test/seams.test.ts && node --experimental-strip-types test/eda-launch.test.ts && node --experimental-strip-types test/schematic-audit.test.ts && node --experimental-strip-types test/design-intake.test.ts && node --experimental-strip-types test/extension-package.test.ts && node --experimental-strip-types test/extension-routes.test.ts && node --experimental-strip-types test/netlist-erc.test.ts && node --experimental-strip-types test/skills.test.ts",
|
|
76
76
|
"seam-check": "node scripts/seam-check.mjs",
|
|
77
77
|
"contract-check": "node scripts/contract-check.mjs",
|
|
78
78
|
"guard:bundle": "node scripts/guard-bundle-paths.mjs",
|
|
@@ -327,6 +327,36 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
|
|
|
327
327
|
const [testing, setTesting] = useState(false)
|
|
328
328
|
const [reconnecting, setReconnecting] = useState(false)
|
|
329
329
|
const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | undefined>()
|
|
330
|
+
const [verifyingExtension, setVerifyingExtension] = useState(false)
|
|
331
|
+
const [extensionVerdict, setExtensionVerdict] = useState<{ kind: 'ok' | 'error'; text: string } | undefined>()
|
|
332
|
+
|
|
333
|
+
const verifyExtension = async (): Promise<void> => {
|
|
334
|
+
if (verifyingExtension) return
|
|
335
|
+
setVerifyingExtension(true)
|
|
336
|
+
try {
|
|
337
|
+
const response = await fetch('/api/dsh-eda-mcp/extension/verify')
|
|
338
|
+
const body = await response.json() as {
|
|
339
|
+
verified?: 'match' | 'mismatch' | 'unverified'
|
|
340
|
+
file?: string
|
|
341
|
+
local?: { sha256?: string }
|
|
342
|
+
error?: string
|
|
343
|
+
}
|
|
344
|
+
if (body.verified === 'match') {
|
|
345
|
+
const short = String(body.local?.sha256 ?? '').slice(0, 16) + '…'
|
|
346
|
+
setExtensionVerdict({ kind: 'ok', text: t('settings.verifyMatch').replace('{hash}', short) })
|
|
347
|
+
} else if (body.verified === 'mismatch') {
|
|
348
|
+
setExtensionVerdict({ kind: 'error', text: t('settings.verifyMismatch').replace('{hash}', String(body.file ?? '')) })
|
|
349
|
+
} else {
|
|
350
|
+
const reason = String(body.error ?? 'unknown')
|
|
351
|
+
setExtensionVerdict({ kind: 'error', text: t('settings.verifyUnverified').replace('{reason}', reason) })
|
|
352
|
+
}
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
355
|
+
setExtensionVerdict({ kind: 'error', text: t('settings.verifyUnverified').replace('{reason}', reason) })
|
|
356
|
+
} finally {
|
|
357
|
+
setVerifyingExtension(false)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
330
360
|
|
|
331
361
|
useEffect(() => {
|
|
332
362
|
if (!seeded && state.status === 'ready') {
|
|
@@ -470,6 +500,17 @@ export function EdaMcpSettingsCard(props: EdaMcpSettingsCardProps) {
|
|
|
470
500
|
>
|
|
471
501
|
{t('settings.downloadExtension')}
|
|
472
502
|
</a>
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
onClick={() => { void verifyExtension() }}
|
|
506
|
+
disabled={verifyingExtension}
|
|
507
|
+
style={verifyingExtension ? { ...secondaryButtonStyle, ...disabledButtonStyle, marginLeft: 8 } : { ...secondaryButtonStyle, marginLeft: 8 }}
|
|
508
|
+
>
|
|
509
|
+
{verifyingExtension ? t('settings.verifyBusy') : t('settings.verifyExtension')}
|
|
510
|
+
</button>
|
|
511
|
+
{extensionVerdict !== undefined
|
|
512
|
+
? <p role="status" style={{ ...hintStyle, margin: '6px 0 0', color: extensionVerdict.kind === 'ok' ? undefined : '#c0392b' }}>{extensionVerdict.text}</p>
|
|
513
|
+
: null}
|
|
473
514
|
</Field>
|
|
474
515
|
</div>
|
|
475
516
|
|
package/src/client/locales.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const zh = {
|
|
|
28
28
|
'settings.reconnectFailed': '重连失败',
|
|
29
29
|
'settings.portLabel': '桥接 WebSocket 端口:39009(固定)',
|
|
30
30
|
'settings.downloadExtension': '下载 JLCEDA 扩展包',
|
|
31
|
+
'settings.verifyExtension': '校验服务器副本',
|
|
32
|
+
'settings.verifyBusy': '校验中…',
|
|
33
|
+
'settings.verifyMatch': '一致:服务器上的 zip 与本地这份逐字节相同(sha256 {hash})',
|
|
34
|
+
'settings.verifyMismatch': '不一致:服务器那份与本地不同,请重新上传 {hash}',
|
|
35
|
+
'settings.verifyUnverified': '无法校验:{reason}',
|
|
31
36
|
'settings.installHint': '在嘉立创EDA专业版中:扩展 → 导入 → 选择下载的 zip → 在扩展列表开启本扩展的「允许外部交互」。',
|
|
32
37
|
} as const
|
|
33
38
|
|
|
@@ -61,6 +66,11 @@ export const en = {
|
|
|
61
66
|
'settings.reconnectFailed': 'Reconnect failed',
|
|
62
67
|
'settings.portLabel': 'Bridge WebSocket port: 39009 (fixed)',
|
|
63
68
|
'settings.downloadExtension': 'Download JLCEDA extension zip',
|
|
69
|
+
'settings.verifyExtension': 'Verify server copy',
|
|
70
|
+
'settings.verifyBusy': 'Verifying…',
|
|
71
|
+
'settings.verifyMatch': 'Match: the served zip is byte-identical to the local one (sha256 {hash})',
|
|
72
|
+
'settings.verifyMismatch': 'Mismatch: the served zip differs — re-upload {hash}',
|
|
73
|
+
'settings.verifyUnverified': 'Could not verify: {reason}',
|
|
64
74
|
'settings.installHint': 'In JLCEDA Pro: Extensions → Import → select the downloaded zip → enable "Allow external interactions" for this extension.',
|
|
65
75
|
} as const
|
|
66
76
|
|
package/src/index.ts
CHANGED
|
@@ -157,6 +157,15 @@ const BRIDGE_URL = new URL('../lib/bridge.mjs', import.meta.url)
|
|
|
157
157
|
* that manifest instead of hard-coding a file name; older layouts without a
|
|
158
158
|
* versioned file keep working through the plain-name fallback.
|
|
159
159
|
*/
|
|
160
|
+
function resolveExtensionVersion(): string {
|
|
161
|
+
try {
|
|
162
|
+
const manifest = JSON.parse(readFileSync(fileURLToPath(new URL('../jlc-extension/extension.json', import.meta.url)), 'utf8')) as { version?: string }
|
|
163
|
+
return typeof manifest.version === 'string' ? manifest.version : ''
|
|
164
|
+
} catch {
|
|
165
|
+
return ''
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
160
169
|
function resolveExtensionZipPath(): string {
|
|
161
170
|
const plain = fileURLToPath(new URL('../dsh-eda-bridge-extension.zip', import.meta.url))
|
|
162
171
|
try {
|
|
@@ -292,6 +301,8 @@ let current: () => EdaMcpConfig = () => unwrapConfig(config ?? {})
|
|
|
292
301
|
const disposers = makeEdaMcpRoutes({
|
|
293
302
|
getConfig: () => current(),
|
|
294
303
|
extensionZipPath: resolveExtensionZipPath(),
|
|
304
|
+
extensionVersion: resolveExtensionVersion(),
|
|
305
|
+
extensionDownloadBase: process.env.DSH_EDA_EXTENSION_BASE ?? 'http://159.75.130.207/eda/',
|
|
295
306
|
reconnect: remountClient,
|
|
296
307
|
bridgeLaunch: describeBridgeLaunch,
|
|
297
308
|
// Surface the skill registration so "did it register?" is answerable from
|
package/src/routes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
3
|
import type { ServerResponse } from 'node:http'
|
|
4
4
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
@@ -27,11 +27,13 @@ export interface SettingsBridge {
|
|
|
27
27
|
}
|
|
28
28
|
import type { EdaMcpConfig } from './index.ts'
|
|
29
29
|
import { isLoopbackRequest } from './loopback.ts'
|
|
30
|
+
import { compareDigest, extensionDownloadUrl, sha256Of } from './bridge/extension-package.ts'
|
|
30
31
|
|
|
31
32
|
export const EDA_MCP_API = {
|
|
32
33
|
test: '/api/dsh-eda-mcp/test',
|
|
33
34
|
reconnect: '/api/dsh-eda-mcp/reconnect',
|
|
34
35
|
extensionZip: '/api/dsh-eda-mcp/extension.zip',
|
|
36
|
+
extensionVerify: '/api/dsh-eda-mcp/extension/verify',
|
|
35
37
|
diagnostics: '/api/dsh-eda-mcp/diagnostics',
|
|
36
38
|
selfTest: '/api/dsh-eda-mcp/self-test',
|
|
37
39
|
settingsDescribe: '/api/dsh-eda-mcp/settings/describe',
|
|
@@ -87,6 +89,10 @@ function toView(descriptor: SettingsBridgeView): unknown {
|
|
|
87
89
|
export interface EdaMcpRouteDeps {
|
|
88
90
|
getConfig: () => EdaMcpConfig
|
|
89
91
|
extensionZipPath: string
|
|
92
|
+
/** Wire version of the bundled extension zip (from jlc-extension/extension.json). */
|
|
93
|
+
extensionVersion: string
|
|
94
|
+
/** Base URL of the download server; override with DSH_EDA_EXTENSION_BASE. */
|
|
95
|
+
extensionDownloadBase: string
|
|
90
96
|
/** Dispose and recreate the MCP bridge child, then return immediately. */
|
|
91
97
|
reconnect: () => void
|
|
92
98
|
/** Which executable and script the bridge child is started with (diagnostics). */
|
|
@@ -179,25 +185,82 @@ export function makeEdaMcpRoutes(deps: EdaMcpRouteDeps): WebRoute[] {
|
|
|
179
185
|
},
|
|
180
186
|
}
|
|
181
187
|
|
|
188
|
+
// The card serves the LOCAL copy on purpose: it works offline and cannot be throttled.
|
|
189
|
+
// The digest travels with it, and a separate verify route compares it with the served copy
|
|
190
|
+
// so "is this the same artifact?" has an answer without downloading twice by hand.
|
|
182
191
|
const extensionZip: WebRoute = {
|
|
183
192
|
kind: 'exact',
|
|
184
193
|
path: EDA_MCP_API.extensionZip,
|
|
185
194
|
handler: async (req, res): Promise<void> => {
|
|
186
195
|
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
187
196
|
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
|
|
197
|
+
let bytes: Buffer
|
|
198
|
+
try {
|
|
199
|
+
bytes = await readFile(deps.extensionZipPath)
|
|
200
|
+
} catch {
|
|
201
|
+
return json(res, 404, { ok: false, error: 'extension zip missing next to the plugin' })
|
|
202
|
+
}
|
|
188
203
|
res.writeHead(200, {
|
|
189
204
|
'content-type': 'application/zip',
|
|
190
205
|
// Keep the versioned file name the browser saves it as.
|
|
191
206
|
'content-disposition': 'attachment; filename="' + basename(deps.extensionZipPath) + '"',
|
|
192
207
|
'cache-control': 'no-store',
|
|
208
|
+
'x-extension-version': deps.extensionVersion,
|
|
209
|
+
'x-extension-sha256': sha256Of(bytes),
|
|
210
|
+
'content-length': String(bytes.byteLength),
|
|
211
|
+
})
|
|
212
|
+
res.end(bytes)
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const extensionVerify: WebRoute = {
|
|
217
|
+
kind: 'exact',
|
|
218
|
+
path: EDA_MCP_API.extensionVerify,
|
|
219
|
+
handler: async (req, res): Promise<void> => {
|
|
220
|
+
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method-not-allowed' })
|
|
221
|
+
if (!isLoopbackRequest(req)) return json(res, 403, { ok: false, error: 'forbidden' })
|
|
222
|
+
let localBytes: Buffer
|
|
223
|
+
try {
|
|
224
|
+
localBytes = await readFile(deps.extensionZipPath)
|
|
225
|
+
} catch {
|
|
226
|
+
return json(res, 404, { ok: false, error: 'extension zip missing next to the plugin' })
|
|
227
|
+
}
|
|
228
|
+
const localSha256 = sha256Of(localBytes)
|
|
229
|
+
const url = extensionDownloadUrl(deps.extensionDownloadBase, deps.extensionVersion)
|
|
230
|
+
const report: Record<string, unknown> = {
|
|
231
|
+
version: deps.extensionVersion,
|
|
232
|
+
file: basename(deps.extensionZipPath),
|
|
233
|
+
local: { bytes: localBytes.byteLength, sha256: localSha256 },
|
|
234
|
+
url,
|
|
235
|
+
}
|
|
236
|
+
let response: Response
|
|
237
|
+
try {
|
|
238
|
+
response = await fetch(url, { signal: AbortSignal.timeout(20000) })
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return json(res, 200, { ...report, verified: 'unverified', error: 'download failed: ' + (error instanceof Error ? error.message : String(error)) })
|
|
241
|
+
}
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
// 429 = the server throttles an IP to 5 downloads/min; that is not a checksum problem.
|
|
244
|
+
return json(res, 200, {
|
|
245
|
+
...report,
|
|
246
|
+
verified: 'unverified',
|
|
247
|
+
httpStatus: response.status,
|
|
248
|
+
error: response.status === 429
|
|
249
|
+
? 'the download server throttled this IP (HTTP 429, 5 downloads/min). Wait about a minute and verify again.'
|
|
250
|
+
: 'download failed: HTTP ' + response.status,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
const remoteBytes = new Uint8Array(await response.arrayBuffer())
|
|
254
|
+
const remoteSha256 = sha256Of(remoteBytes)
|
|
255
|
+
return json(res, 200, {
|
|
256
|
+
...report,
|
|
257
|
+
remote: { bytes: remoteBytes.byteLength, sha256: remoteSha256 },
|
|
258
|
+
verified: compareDigest(localSha256, remoteSha256),
|
|
193
259
|
})
|
|
194
|
-
const stream = createReadStream(deps.extensionZipPath)
|
|
195
|
-
stream.on('error', () => { res.destroy() })
|
|
196
|
-
stream.pipe(res)
|
|
197
260
|
},
|
|
198
261
|
}
|
|
199
262
|
|
|
200
|
-
return [test, reconnect, extensionZip, diagnostics, selfTest]
|
|
263
|
+
return [test, reconnect, extensionZip, extensionVerify, diagnostics, selfTest]
|
|
201
264
|
}
|
|
202
265
|
|
|
203
266
|
export function makeEdaMcpSettingsRoutes(settings: SettingsBridge): WebRoute[] {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { makeEdaMcpRoutes, EDA_MCP_API } from '../src/routes.ts'
|
|
5
|
+
import { sha256Of } from '../src/bridge/extension-package.ts'
|
|
6
|
+
|
|
7
|
+
const zipPath = 'dsh-eda-bridge-extension-0.4.9.zip'
|
|
8
|
+
const localBytes = readFileSync(zipPath)
|
|
9
|
+
const localSha = sha256Of(localBytes)
|
|
10
|
+
|
|
11
|
+
// Minimal IncomingMessage/ServerResponse doubles: the handlers only use method, socket,
|
|
12
|
+
// url, writeHead, end and (for errors) destroy.
|
|
13
|
+
function fakeReq(method: string) {
|
|
14
|
+
return { method, url: '/', socket: { remoteAddress: '127.0.0.1' }, headers: { host: '127.0.0.1' } }
|
|
15
|
+
}
|
|
16
|
+
function fakeRes() {
|
|
17
|
+
const captured: { status?: number; headers?: Record<string, string>; body?: unknown; bytes?: Buffer } = {}
|
|
18
|
+
const res = {
|
|
19
|
+
writeHead(status: number, headers: Record<string, string>) { captured.status = status; captured.headers = headers; return res },
|
|
20
|
+
end(payload?: unknown) { if (Buffer.isBuffer(payload)) captured.bytes = payload; else if (typeof payload === 'string') captured.body = payload; return res },
|
|
21
|
+
destroy() { return res },
|
|
22
|
+
getHeader() { return undefined },
|
|
23
|
+
setHeader() { return res },
|
|
24
|
+
}
|
|
25
|
+
return { res, captured }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deps(base: string) {
|
|
29
|
+
return {
|
|
30
|
+
getConfig: () => ({}),
|
|
31
|
+
extensionZipPath: zipPath,
|
|
32
|
+
extensionVersion: '0.4.9',
|
|
33
|
+
extensionDownloadBase: base,
|
|
34
|
+
reconnect: () => {},
|
|
35
|
+
bridgeLaunch: () => ({ command: 'node', args: [], source: 'test' }),
|
|
36
|
+
diagnostics: () => ({}),
|
|
37
|
+
launchConfig: () => ({}),
|
|
38
|
+
selfTest: async () => ({}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function routeOf(routes: ReturnType<typeof makeEdaMcpRoutes>, path: string) {
|
|
43
|
+
const route = routes.find(r => r.path === path)
|
|
44
|
+
assert.ok(route, 'route ' + path + ' must exist')
|
|
45
|
+
return route
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function withServer(handler: Parameters<typeof createServer>[1], run: (base: string) => Promise<void>) {
|
|
49
|
+
const server = createServer(handler)
|
|
50
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
51
|
+
const port = (server.address() as { port: number }).port
|
|
52
|
+
try { await run('http://127.0.0.1:' + port + '/eda/') } finally { await new Promise<void>(resolve => server.close(() => resolve())) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1. the zip route stamps the digest it is handing out
|
|
56
|
+
{
|
|
57
|
+
const routes = makeEdaMcpRoutes(deps('http://127.0.0.1:1/eda/'))
|
|
58
|
+
const { res, captured } = fakeRes()
|
|
59
|
+
await routeOf(routes, EDA_MCP_API.extensionZip).handler(fakeReq('GET') as never, res as never)
|
|
60
|
+
assert.equal(captured.status, 200)
|
|
61
|
+
assert.equal(captured.headers?.['x-extension-sha256'], localSha, 'digest header must describe the bytes served')
|
|
62
|
+
assert.equal(captured.headers?.['content-length'], String(localBytes.byteLength))
|
|
63
|
+
assert.equal(captured.headers?.['content-disposition']?.includes(zipPath), true, 'versioned file name is kept')
|
|
64
|
+
assert.equal(sha256Of(captured.bytes as Buffer), localSha, 'served bytes are the local zip')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. identical remote copy -> match
|
|
68
|
+
await withServer((_req, res) => { res.writeHead(200, { 'content-type': 'application/zip' }); res.end(localBytes) }, async (base) => {
|
|
69
|
+
const { res, captured } = fakeRes()
|
|
70
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
|
|
71
|
+
const body = JSON.parse(String(captured.body))
|
|
72
|
+
assert.equal(body.verified, 'match', 'identical bytes verify')
|
|
73
|
+
assert.equal(body.local.sha256, localSha)
|
|
74
|
+
assert.equal(body.remote.sha256, localSha)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// 3. different remote copy -> mismatch (never silently equal)
|
|
78
|
+
await withServer((_req, res) => { res.writeHead(200); res.end(Buffer.from('different bytes')) }, async (base) => {
|
|
79
|
+
const { res, captured } = fakeRes()
|
|
80
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
|
|
81
|
+
const body = JSON.parse(String(captured.body))
|
|
82
|
+
assert.equal(body.verified, 'mismatch')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// 4. throttled remote copy -> unverified + status, not a mismatch
|
|
86
|
+
await withServer((_req, res) => { res.writeHead(429, { 'content-type': 'text/plain' }); res.end('slow down') }, async (base) => {
|
|
87
|
+
const { res, captured } = fakeRes()
|
|
88
|
+
await routeOf(makeEdaMcpRoutes(deps(base)), EDA_MCP_API.extensionVerify).handler(fakeReq('GET') as never, res as never)
|
|
89
|
+
const body = JSON.parse(String(captured.body))
|
|
90
|
+
assert.equal(body.verified, 'unverified', '429 is throttling, not a checksum mismatch')
|
|
91
|
+
assert.equal(body.httpStatus, 429)
|
|
92
|
+
assert.equal(String(body.error).includes('429'), true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
console.log('ALL PASS extension-routes')
|