zen-gitsync 2.17.47 → 2.17.49
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/README.md +16 -2
- package/package.json +2 -1
- package/src/cli/ai/mcp.js +323 -27
- package/src/cli/ai/mcp.test.js +202 -2
- package/src/ui/public/assets/{AgentEngineSelector-CKiVaqrY.js → AgentEngineSelector-Bm_wrfGT.js} +1 -1
- package/src/ui/public/assets/AgentView-BwqOHAs3.js +1 -0
- package/src/ui/public/assets/{AgentView-CRqQYAzh.css → AgentView-uHf-KMCH.css} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-IWS9XBUV.js → AppVersionBadge-DyhIkbbF.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-C_mK2mkS.js → BranchSelector-CDQZlH-y.js} +1 -1
- package/src/ui/public/assets/{CommitForm-C6I9rQXn.js → CommitForm-DpIXE8z1.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-2zVHBuqT.js → CommonDialog-NqZURH2F.js} +1 -1
- package/src/ui/public/assets/EditorView-0T5LR5Hz.css +1 -0
- package/src/ui/public/assets/EditorView-BC6V8V5_.js +1 -0
- package/src/ui/public/assets/{FlowExecutionViewer-0Xw0oDen.js → FlowExecutionViewer-DxDKzmaP.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BKUspY-s.js → FlowOrchestrationWorkspace-BHASB5iW.js} +1 -1
- package/src/ui/public/assets/{LogList-pbwn5n2S.js → LogList-CdtFyrLd.js} +1 -1
- package/src/ui/public/assets/{MindmapView-DYh6nuuF.js → MindmapView-2tt7W7A3.js} +1 -1
- package/src/ui/public/assets/{MonitorView-CsJyUL9v.js → MonitorView--IW5ll8A.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-BhFQfAX1.js → ProjectStartupButton-CWKSZVGw.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-DINPN4ju.js +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-THkqWEtO.css +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-CIZagKkC.js → RemoteManagerDialog-BVwpcCAu.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-Dj-B-6WU.js → RemoteRepoCard-Dy3AF_XZ.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-pIlmQzYw.js → SourceMapView-BnHeu5wE.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-CaefOO1F.js → SvgIcon-DcVasCdo.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-1--I25vm.js → UserInputNode-B313MadX.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-0cY9WHPM.js +20 -0
- package/src/ui/public/assets/WorkbenchView-rscs76HF.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-UTW98kcD.js → _plugin-vue_export-helper-Bsx6Zj3t.js} +5 -5
- package/src/ui/public/assets/agentConversations-BnG4a1oH.js +8 -0
- package/src/ui/public/assets/{configStore-H-n3ukZ_.js → configStore-DQSKV2_Y.js} +1 -1
- package/src/ui/public/assets/{dagre-Br4Eexe7.js → dagre-BmYeUlEa.js} +3 -3
- package/src/ui/public/assets/{element-plus-CZdvD4lq.js → element-plus-DTtm3XCd.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-4kZ-2J2Y.js → flow-mindmap-JxOspgwx.js} +1 -1
- package/src/ui/public/assets/{index-CD7otmb_.js → index-BApctCNq.js} +7 -7
- package/src/ui/public/assets/{monaco-CoYCcK3Q.js → monaco-D3R9OM26.js} +1 -1
- package/src/ui/public/assets/{office-docx-DndLP6GO.js → office-docx-CO7mIaWO.js} +1 -1
- package/src/ui/public/assets/{office-excel-Jt5zzo6D.js → office-excel-gA4KEkK4.js} +1 -1
- package/src/ui/public/assets/{office-pptx-J8tzRTjU.js → office-pptx-CQK4l_1Q.js} +1 -1
- package/src/ui/public/assets/{vendor-BD09UDuP.js → vendor-O17eRefg.js} +497 -497
- package/src/ui/public/assets/vendor-U3ZXMgeO.css +1 -0
- package/src/ui/public/assets/{vue-flow-CkM05TWt.js → vue-flow-BG6ZF1It.js} +1 -1
- package/src/ui/public/index.html +12 -12
- package/src/ui/server/routes/workbench/agentMarketplace.js +65 -30
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +28 -4
- package/src/ui/server/routes/workbench/liveJobs.js +4 -0
- package/src/ui/server/routes/workbench/liveJobs.test.js +11 -0
- package/src/ui/server/routes/workbench/taskRunner.js +44 -7
- package/src/ui/server/routes/workbench/taskRunner.thinking.test.js +115 -0
- package/src/ui/public/assets/AgentView-c1frCBc3.js +0 -1
- package/src/ui/public/assets/EditorView-DUJrWJgh.js +0 -1
- package/src/ui/public/assets/EditorView-DqagGedH.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BxbpKrXc.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-DM6sRO9L.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BLzcZqy0.css +0 -1
- package/src/ui/public/assets/WorkbenchView-bpzL6m84.js +0 -20
- package/src/ui/public/assets/agentConversations-B9LNft3v.js +0 -8
- package/src/ui/public/assets/vendor-DNiTBOIX.css +0 -1
package/README.md
CHANGED
|
@@ -444,7 +444,7 @@ A dedicated view (robot icon in the activity bar) for chatting with the built-in
|
|
|
444
444
|
| Task plan | Multi-step work gets a visible plan: the agent calls the built-in `update_plan` tool to split the task into 3-8 verifiable steps before touching anything, then updates each step's status as it goes. Steps render as a checklist with completed / in-progress / pending states and a `2/5` progress header — in the terminal as a `✓ / ▶ / ○` list, in the Web panel as a card that **stays visible even when the tool group is collapsed** (collapsing hides other tool calls, never the current plan) |
|
|
445
445
|
| Recent-projects awareness | Ask "which of my projects need a pull?" and the agent calls its built-in `list_projects` tool instead of scanning the disk: it returns exactly the list behind the GUI's **Recent projects** panel (recent directories plus any directory a task was created in, with branch / ahead / behind / uncommitted counts and task progress), so the agent's answer and the UI agree. Ahead/behind reads local refs, so the agent can pass `refresh=true` to run a `git fetch` pass first when the question is about pulling |
|
|
446
446
|
| Session persistence | All conversations are saved to `~/.zen-gitsync/agent-sessions/` as JSON files; the CLI agent (`g ai`) writes to the same directory so Web and CLI sessions are unified |
|
|
447
|
-
| Skill / MCP plaza | The **Skill plaza** and **MCP plaza** tabs list skills and MCP servers from several sources, each with its description, weekly downloads / usage count and install state. Install one into the **current project** (`<project>/.zen-gitsync/ai/skills/<id>/SKILL.md` and `<project>/.zen-gitsync/ai/mcp.json`) or into the **`g ai` agent** (`~/.zen-gitsync/ai/`, applying to every project) — zen-gitsync's own directories, not another tool's. Entries already installed can be opened in the system file manager or uninstalled from the same row, and ones that still need environment variables are flagged. The installed list shows both the skill's own `name` and the on-disk id, since a repository often ships a skill whose `SKILL.md` calls itself something else. From a terminal, `g ai` lists what is installed with `/skills` (`/mcp` is an alias) |
|
|
447
|
+
| Skill / MCP plaza | The **Skill plaza** and **MCP plaza** tabs list skills and MCP servers from several sources, each with its description, weekly downloads / usage count and install state. Install one into the **current project** (`<project>/.zen-gitsync/ai/skills/<id>/SKILL.md` and `<project>/.zen-gitsync/ai/mcp.json`) or into the **`g ai` agent** (`~/.zen-gitsync/ai/`, applying to every project) — zen-gitsync's own directories, not another tool's. Entries already installed can be opened in the system file manager or uninstalled from the same row, and ones that still need environment variables are flagged. The installed list shows both the skill's own `name` and the on-disk id, since a repository often ships a skill whose `SKILL.md` calls itself something else. From a terminal, `g ai` lists what is installed with `/skills` (`/mcp` is an alias). Entries land in one of two shapes: an npm package (`command: npx …`, i.e. stdio) or a remote endpoint (`type: http` + `url` + optional `headers`, which the agent's Streamable HTTP client talks to directly — no `mcp-remote` bridge in between) |
|
|
448
448
|
| SSH-first cloning | When you ask it to clone a repo (or add a remote) it uses the SSH form — `git@github.com:owner/repo.git` / `git@gitee.com:owner/repo.git` — converting an `https://` URL first, so the clone never stalls on a Git Credential Manager username/password prompt; it falls back to https only when SSH genuinely fails (`Permission denied (publickey)` / host-key verification) and says which one it used. The same preference is injected into every workbench task, whose executor is an external CLI with a system prompt this app does not own |
|
|
449
449
|
| Per-turn tool limit | A single message may trigger up to N tool calls in a row (default **200**, range 1–2000). Configurable in **Settings → AI models → Agent Runtime**; hitting the limit ends the turn and asks you to send another message. The same setting drives the CLI agent |
|
|
450
450
|
| Preset questions | Quick-start buttons on the welcome screen for common tasks (view project structure, analyze code quality, write tests, check git status, start the project) |
|
|
@@ -541,6 +541,14 @@ where they came from. Installation itself happens in the GUI's **Skill / MCP pla
|
|
|
541
541
|
view): pick the current project or the `g ai` agent as the target, and the entry becomes usable
|
|
542
542
|
from that side.
|
|
543
543
|
|
|
544
|
+
`g ai` speaks both MCP transports: an entry with a `command` runs over **stdio** (a child
|
|
545
|
+
process), one with only a `url` over **Streamable HTTP** (`"type": "http"`, plus `headers` for
|
|
546
|
+
things like `Authorization`; `Mcp-Session-Id` is echoed back and the session is closed with
|
|
547
|
+
`DELETE` on exit). HTTPS endpoints are verified against Node's bundled root CAs rather than the
|
|
548
|
+
OS store, so a server that ships only its leaf certificate fails with
|
|
549
|
+
`UNABLE_TO_VERIFY_LEAF_SIGNATURE` while browsers are perfectly happy — start the agent with
|
|
550
|
+
`NODE_EXTRA_CA_CERTS=<ca file>`, or on Node ≥ 22.15 with `NODE_OPTIONS=--use-system-ca`.
|
|
551
|
+
|
|
544
552
|
In-session commands: `/help`, `/model`, `/addmodel`, `/cd <path>`, `/image [path]`, `/think`, `/tools`, `/stats`, `/new`, `/resume`, `/skills` (`/mcp` is an alias), `/clear`, `/exit` (or `/quit`).
|
|
545
553
|
|
|
546
554
|
Reasoning, tool calls and answers have separate visual sections. Reasoning returned by the model is shown in full by default;
|
|
@@ -1165,7 +1173,7 @@ Activity Bar 中的机器人图标视图,可直接在浏览器中与内置 AI
|
|
|
1165
1173
|
| 任务计划 | 多步任务有一份看得见的计划:智能体在动手之前先调内置的 `update_plan` 工具,把任务拆成 3-8 个可核对的步骤,随后逐步更新状态。步骤以清单渲染,区分完成 / 进行中 / 待办三态,标题右侧带 `2/5` 进度 —— 终端里是 `✓ / ▶ / ○` 列表,Web 面板里是一张卡片,且**工具组折叠时仍然常驻**(折叠只藏别的工具调用,绝不藏当前计划) |
|
|
1166
1174
|
| 最近项目感知 | 问「我哪些项目需要 pull」时,智能体调用内置的 `list_projects` 工具,而不是自己去扫盘:返回的就是 GUI「最近项目」面板那份清单(最近目录 + 建过任务的目录,带分支 / 领先 / 落后 / 未提交数与任务进度),回答与界面对得上。领先/落后读的是本地引用,因此问到"要不要拉"时它可以带 `refresh=true` 先联网 fetch 一轮再答 |
|
|
1167
1175
|
| 会话持久化 | 所有对话保存为 JSON 文件到 `~/.zen-gitsync/agent-sessions/`;CLI 智能体(`g ai`)写入同一目录,Web 端与 CLI 端会话统一管理 |
|
|
1168
|
-
| Skill / MCP 广场 | **Skill 广场** 与 **MCP 广场** 两个 tab 列出多个来源的 Skill 与 MCP 服务,每项带说明、周下载 / 使用次数与安装状态。可安装到**当前项目**(`<项目>/.zen-gitsync/ai/skills/<id>/SKILL.md` 与 `<项目>/.zen-gitsync/ai/mcp.json`)或 **`g ai` 智能体**(`~/.zen-gitsync/ai/`,对所有项目生效)—— 两处都是 zen-gitsync 自己的目录,不借别家工具的。已安装的可在同一行「打开文件夹」定位到落盘位置,或直接卸载,还缺环境变量的会标出「还缺环境变量」。已安装清单同时显示 skill 自报的 `name` 和实际落盘的目录 id —— 仓库名和 `SKILL.md` 里自称的名字经常不是一个。终端侧 `g ai` 用 `/skills`(`/mcp`
|
|
1176
|
+
| Skill / MCP 广场 | **Skill 广场** 与 **MCP 广场** 两个 tab 列出多个来源的 Skill 与 MCP 服务,每项带说明、周下载 / 使用次数与安装状态。可安装到**当前项目**(`<项目>/.zen-gitsync/ai/skills/<id>/SKILL.md` 与 `<项目>/.zen-gitsync/ai/mcp.json`)或 **`g ai` 智能体**(`~/.zen-gitsync/ai/`,对所有项目生效)—— 两处都是 zen-gitsync 自己的目录,不借别家工具的。已安装的可在同一行「打开文件夹」定位到落盘位置,或直接卸载,还缺环境变量的会标出「还缺环境变量」。已安装清单同时显示 skill 自报的 `name` 和实际落盘的目录 id —— 仓库名和 `SKILL.md` 里自称的名字经常不是一个。终端侧 `g ai` 用 `/skills`(`/mcp` 为别名)查看已装清单。装下来的条目就两类形状:npm 包(落成 `command: npx …`,走 stdio)与远程端点(落成 `type: http` + `url` + 可选 `headers`,由 g ai 的 Streamable HTTP 客户端直连,中间不再经 `mcp-remote` 桥接) |
|
|
1169
1177
|
| 克隆优先 SSH | 让它克隆仓库(或加远端)时走 SSH 形式 —— `git@github.com:owner/repo.git` / `git@gitee.com:owner/repo.git`;拿到 `https://` 地址先换算,克隆不会停在 Git Credential Manager 的账号密码弹窗上。只有 SSH 真的不可用(`Permission denied (publickey)` / 主机密钥校验失败)才退回 https,并说明这次走的是哪条。同一条偏好也会注入到每个工作台任务的 prompt —— 那里执行器是外部 CLI,系统提示词不归本应用管,环境上下文块是唯一的注入口 |
|
|
1170
1178
|
| 单轮工具调用上限 | 一条消息内智能体最多连续调用多少次工具(默认 **200**,可调范围 1–2000)。在 **设置 → AI 模型配置 → 智能体运行时** 中修改;达到上限本轮会被强制结束并提示再发一条消息继续。CLI 智能体共用同一项设置 |
|
|
1171
1179
|
| 预设问题 | 开场界面提供快捷按钮(查看项目结构、分析代码质量、写测试、Git 状态检查、帮我启动项目)|
|
|
@@ -1252,6 +1260,12 @@ $ g ai --model=2 # 使用第 2 个已配置的模型(序号或
|
|
|
1252
1260
|
`/skills`(`/mcp` 为别名)列出智能体当前已安装的 Skill 与 MCP 服务及来源。安装本身在 GUI 的
|
|
1253
1261
|
**Skill / MCP 广场**(智能体视图)里完成:选择安装到当前项目或 `g ai` 智能体,装好后对应一侧即可使用。
|
|
1254
1262
|
|
|
1263
|
+
`g ai` 两种 MCP 传输都支持:有 `command` 的走 **stdio**(起子进程),只有 `url` 的走
|
|
1264
|
+
**Streamable HTTP**(配置写 `"type": "http"`,鉴权放 `headers`,例如 `Authorization`;
|
|
1265
|
+
响应里的 `Mcp-Session-Id` 会回传,退出时 `DELETE` 结束会话)。HTTPS 端点按 **Node 内置根证书**
|
|
1266
|
+
校验、不读系统证书库 —— 站点只发叶证书时会报 `UNABLE_TO_VERIFY_LEAF_SIGNATURE`(浏览器却一切正常),
|
|
1267
|
+
启动前设 `NODE_EXTRA_CA_CERTS=<CA 文件>`,或 Node ≥ 22.15 时用 `NODE_OPTIONS=--use-system-ca` 即可。
|
|
1268
|
+
|
|
1255
1269
|
会话内命令:`/help`、`/model`、`/addmodel`、`/cd <路径>`、`/image [路径]`、`/think`、`/tools`、`/stats`、`/new`、`/resume`、`/skills`(`/mcp` 为别名)、`/clear`、`/exit`(或 `/quit`)。
|
|
1256
1270
|
|
|
1257
1271
|
思考、工具调用和回答分区展示。默认完整显示模型返回的思考,`/think full` 恢复完整显示,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.49",
|
|
4
4
|
"description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"verify:wb-responsive": "node scripts/verify-wb-responsive.cjs",
|
|
78
78
|
"verify:wb-editor-persist": "node scripts/verify-workbench-editor-persist.cjs",
|
|
79
79
|
"verify:wb-open-scroll-bottom": "node scripts/verify-workbench-open-scroll-bottom.cjs",
|
|
80
|
+
"verify:chat-rail": "node scripts/verify-chat-message-rail.cjs",
|
|
80
81
|
"verify:editor-preview-scroll": "node scripts/verify-editor-preview-scroll.cjs",
|
|
81
82
|
"verify:editor-sidebar-width": "node scripts/verify-editor-sidebar-width.cjs",
|
|
82
83
|
"verify:editor-disk-sync": "node scripts/verify-editor-disk-sync.cjs",
|
package/src/cli/ai/mcp.js
CHANGED
|
@@ -12,19 +12,32 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
//
|
|
15
|
-
// g ai 的 MCP(Model Context Protocol)客户端 — stdio
|
|
15
|
+
// g ai 的 MCP(Model Context Protocol)客户端 — 支持 stdio 与 Streamable HTTP 两种传输。
|
|
16
16
|
//
|
|
17
17
|
// 为什么自己实现而不是装 SDK:
|
|
18
|
-
// 本包 dependencies
|
|
19
|
-
// (
|
|
20
|
-
//
|
|
18
|
+
// 本包 dependencies 一直保持精简,而这两种传输的协议面都很小
|
|
19
|
+
// (JSON-RPC + initialize / tools/list / tools/call 三个方法),自己写换取
|
|
20
|
+
// 零依赖 + 完全可控的超时与降级行为。
|
|
21
21
|
//
|
|
22
22
|
// 配置来源(两级,项目级覆盖全局同名 server):
|
|
23
23
|
// 1) ~/.zen-gitsync/ai/mcp.json 「g ai 智能体」全局安装(对所有项目生效)
|
|
24
24
|
// 2) <cwd>/.zen-gitsync/ai/mcp.json 项目级安装
|
|
25
25
|
// 形状沿用生态通行的 mcpServers。项目级路径常量与广场安装端共用 src/paths.js 那一份;
|
|
26
26
|
// 项目级**不再读** <cwd>/.mcp.json(那是 Claude Code 一系的约定路径,2026-09-30 迁出)。
|
|
27
|
-
//
|
|
27
|
+
//
|
|
28
|
+
// 两种条目形状,按有没有 command 自动区分(同一条目同时有两者时以 command 为准):
|
|
29
|
+
// { "github": { "command": "npx", "args": ["-y", "@..."], "env": {} } }
|
|
30
|
+
// { "todo": { "type": "http", "url": "https://.../api/mcp", "headers": { "Authorization": "Bearer ..." } } }
|
|
31
|
+
//
|
|
32
|
+
// HTTP 侧只说 Streamable HTTP(2025-03-26 起规范里的那套):
|
|
33
|
+
// POST JSON-RPC → 200 application/json 或 text/event-stream(取 id 匹配的那一帧);
|
|
34
|
+
// 响应头里的 Mcp-Session-Id 会被记住并在后续请求回传;关闭时尽力 DELETE 结束会话。
|
|
35
|
+
// 老式 "HTTP+SSE"(2024-11-05 的 GET /sse + POST endpoint)不在此列 —— 那种服务端
|
|
36
|
+
// 自己也在陆续迁走,真需要时用 mcp-remote 桥成 stdio 更省事。
|
|
37
|
+
//
|
|
38
|
+
// ⚠️ HTTPS 的证书链:fetch 走 Node 内置根证书,不读 Windows/macOS 证书库。
|
|
39
|
+
// 站点只发叶证书、中间证书没带全时,Node 会报 unable to verify the first certificate,
|
|
40
|
+
// 而浏览器/curl 都正常。这里**不擅自关校验**(那是安全红线),只在报错里把可操作的做法写清楚。
|
|
28
41
|
//
|
|
29
42
|
// ⚠️ Windows 上 spawn 的两个坑(已实测,不要"优化"掉):
|
|
30
43
|
// 1) 带空格的参数在 shell:true 下会被 cmd 拆碎 ——
|
|
@@ -46,6 +59,11 @@ const IS_WIN = process.platform === 'win32';
|
|
|
46
59
|
// 工具名前缀。OpenAI function calling 允许 [A-Za-z0-9_-],用双下划线分段。
|
|
47
60
|
export const MCP_TOOL_PREFIX = 'mcp__';
|
|
48
61
|
|
|
62
|
+
// 握手时声明的协议版本。stdio 沿用旧值不动(现有 server 都是照这个档位对接的);
|
|
63
|
+
// HTTP 侧必须报 Streamable HTTP 那一版,否则部分服务端会按老式 HTTP+SSE 处理。
|
|
64
|
+
const STDIO_PROTOCOL_VERSION = '2024-11-05';
|
|
65
|
+
const HTTP_PROTOCOL_VERSION = '2025-03-26';
|
|
66
|
+
|
|
49
67
|
// 初始化握手超时。
|
|
50
68
|
// ⚠️ 别按"握手只需要几百毫秒"来定这个值:command 是 npx -y 时,**首次**会现场下载
|
|
51
69
|
// 整个 server 包及其依赖。国内直连 registry(无代理)实测这一步能轻松吃掉 30s+,
|
|
@@ -144,6 +162,9 @@ export function buildSpawnSpec(command, args, env) {
|
|
|
144
162
|
/**
|
|
145
163
|
* 读取一个 mcp.json 形状的配置文件,返回 mcpServers 对象。
|
|
146
164
|
* 文件不存在 / 坏了都只当"没有配置",不抛错 —— 一个坏文件不该让 g ai 起不来。
|
|
165
|
+
*
|
|
166
|
+
* 保留条件:有 command(stdio)或有 http(s) 的 url(Streamable HTTP)。
|
|
167
|
+
* 两者都没有的条目直接丢掉 —— 免得后面按 undefined 去 spawn 炸得莫名其妙。
|
|
147
168
|
*/
|
|
148
169
|
async function readServersFile(file) {
|
|
149
170
|
const raw = await fs.readFile(file, 'utf8').catch(() => '');
|
|
@@ -155,7 +176,9 @@ async function readServersFile(file) {
|
|
|
155
176
|
const out = {};
|
|
156
177
|
for (const [id, cfg] of Object.entries(servers)) {
|
|
157
178
|
if (!cfg || typeof cfg !== 'object') continue;
|
|
158
|
-
|
|
179
|
+
const hasCommand = typeof cfg.command === 'string' && cfg.command.trim() !== '';
|
|
180
|
+
const hasUrl = typeof cfg.url === 'string' && /^https?:\/\//i.test(cfg.url.trim());
|
|
181
|
+
if (!hasCommand && !hasUrl) continue;
|
|
159
182
|
out[id] = cfg;
|
|
160
183
|
}
|
|
161
184
|
return out;
|
|
@@ -187,11 +210,55 @@ export async function loadMcpServers({ cwd, globalFile = AI_MCP_FILE, projectFil
|
|
|
187
210
|
// 单个 server 的连接
|
|
188
211
|
// ──────────────────────────────────────────────
|
|
189
212
|
|
|
213
|
+
/**
|
|
214
|
+
* 这个条目该走哪种传输:有 command 就是 stdio(优先级高于 url),
|
|
215
|
+
* 否则 url 是 http(s) 时走 Streamable HTTP。
|
|
216
|
+
*
|
|
217
|
+
* @param {object} config
|
|
218
|
+
* @returns {'stdio'|'http'|null} null = 两边都不像,调用方应跳过
|
|
219
|
+
*/
|
|
220
|
+
export function serverTransport(config) {
|
|
221
|
+
if (!config || typeof config !== 'object') return null;
|
|
222
|
+
if (typeof config.command === 'string' && config.command.trim() !== '') return 'stdio';
|
|
223
|
+
if (typeof config.url === 'string' && /^https?:\/\//i.test(config.url.trim())) return 'http';
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
190
227
|
// 工具名会被拼进 OpenAI 的 function name,必须收敛到安全字符集
|
|
191
228
|
function sanitizeToolName(name) {
|
|
192
229
|
return String(name || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 48);
|
|
193
230
|
}
|
|
194
231
|
|
|
232
|
+
/**
|
|
233
|
+
* 两种传输共用的握手:initialize → notifications/initialized → tools/list。
|
|
234
|
+
* 请求/通知都通过参数注入,stdio 与 HTTP 各交各的实现。
|
|
235
|
+
*
|
|
236
|
+
* @param {{ id: string, protocolVersion: string,
|
|
237
|
+
* request: (method: string, params: object, timeoutMs: number) => Promise<any>,
|
|
238
|
+
* notify: (method: string, params: object) => any }} io
|
|
239
|
+
* @returns {Promise<Array<object>>} 归一化后的工具定义
|
|
240
|
+
*/
|
|
241
|
+
async function handshake({ id, protocolVersion, request, notify }) {
|
|
242
|
+
await request('initialize', {
|
|
243
|
+
protocolVersion,
|
|
244
|
+
capabilities: {},
|
|
245
|
+
clientInfo: { name: 'zen-gitsync-g-ai', version: '1.0.0' },
|
|
246
|
+
}, INIT_TIMEOUT_MS);
|
|
247
|
+
await notify('notifications/initialized', {});
|
|
248
|
+
|
|
249
|
+
const listed = await request('tools/list', {}, INIT_TIMEOUT_MS);
|
|
250
|
+
return (Array.isArray(listed?.tools) ? listed.tools : [])
|
|
251
|
+
.filter(tool => tool && typeof tool.name === 'string')
|
|
252
|
+
.map(tool => ({
|
|
253
|
+
serverId: id,
|
|
254
|
+
name: tool.name,
|
|
255
|
+
description: tool.description || '',
|
|
256
|
+
inputSchema: tool.inputSchema && typeof tool.inputSchema === 'object'
|
|
257
|
+
? tool.inputSchema
|
|
258
|
+
: { type: 'object', properties: {} },
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
|
|
195
262
|
class McpConnection {
|
|
196
263
|
/**
|
|
197
264
|
* @param {string} id - server 标识(配置里的键名)
|
|
@@ -216,6 +283,11 @@ class McpConnection {
|
|
|
216
283
|
return sanitizeToolName(this.id);
|
|
217
284
|
}
|
|
218
285
|
|
|
286
|
+
/** 传输类型。诊断(/mcp)与 UI 展示用,连接本身不依赖它。 */
|
|
287
|
+
get transport() {
|
|
288
|
+
return 'stdio';
|
|
289
|
+
}
|
|
290
|
+
|
|
219
291
|
/**
|
|
220
292
|
* 启动子进程并完成 initialize + tools/list 握手。
|
|
221
293
|
* 任何失败都写进 this.error 并返回 false,由调用方决定是否提示用户。
|
|
@@ -246,24 +318,12 @@ class McpConnection {
|
|
|
246
318
|
this.spawnFailed = spawnFailed;
|
|
247
319
|
spawnFailed.catch(() => {});
|
|
248
320
|
|
|
249
|
-
await
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const listed = await this.#request('tools/list', {}, INIT_TIMEOUT_MS);
|
|
257
|
-
this.tools = (Array.isArray(listed?.tools) ? listed.tools : [])
|
|
258
|
-
.filter(tool => tool && typeof tool.name === 'string')
|
|
259
|
-
.map(tool => ({
|
|
260
|
-
serverId: this.id,
|
|
261
|
-
name: tool.name,
|
|
262
|
-
description: tool.description || '',
|
|
263
|
-
inputSchema: tool.inputSchema && typeof tool.inputSchema === 'object'
|
|
264
|
-
? tool.inputSchema
|
|
265
|
-
: { type: 'object', properties: {} },
|
|
266
|
-
}));
|
|
321
|
+
this.tools = await handshake({
|
|
322
|
+
id: this.id,
|
|
323
|
+
protocolVersion: STDIO_PROTOCOL_VERSION,
|
|
324
|
+
request: (method, params, timeoutMs) => this.#request(method, params, timeoutMs),
|
|
325
|
+
notify: (method, params) => this.#notify(method, params),
|
|
326
|
+
});
|
|
267
327
|
return true;
|
|
268
328
|
} catch (err) {
|
|
269
329
|
this.error = err.message;
|
|
@@ -360,6 +420,237 @@ class McpConnection {
|
|
|
360
420
|
}
|
|
361
421
|
}
|
|
362
422
|
|
|
423
|
+
// ──────────────────────────────────────────────
|
|
424
|
+
// Streamable HTTP 传输
|
|
425
|
+
// ──────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
// Node 内置根证书不认站点证书链时的错误码 —— 单独识别出来,好把"该怎么修"写进提示
|
|
428
|
+
const TLS_ERROR_CODES = new Set([
|
|
429
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
430
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
431
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
432
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
433
|
+
'CERT_HAS_EXPIRED',
|
|
434
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
function shortText(text, max) {
|
|
438
|
+
const clean = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
439
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 把 fetch 的失败翻译成用户能照着做的中文。
|
|
444
|
+
* 证书链那条是本项目实测踩过的坑:服务端只发叶证书时,浏览器和 curl 都正常,
|
|
445
|
+
* 唯独 Node 报 unable to verify the first certificate —— 只说 "fetch failed" 等于没说。
|
|
446
|
+
*/
|
|
447
|
+
function describeHttpError(err, id) {
|
|
448
|
+
const message = err?.message || String(err);
|
|
449
|
+
// 已经翻译过的(#send 里翻过一次)不要二次加工成"连接 x 失败: 连接 x 失败: ..."
|
|
450
|
+
if (message.startsWith(`连接 "${id}"`)) return message;
|
|
451
|
+
|
|
452
|
+
const cause = err?.cause;
|
|
453
|
+
const code = cause?.code || '';
|
|
454
|
+
if (TLS_ERROR_CODES.has(code)) {
|
|
455
|
+
return `连接 "${id}" 失败:服务器证书链不被 Node 内置根证书信任(${code})。`
|
|
456
|
+
+ '可任选一种办法:① 启动 g ai 前设 NODE_EXTRA_CA_CERTS=<站点 CA 文件路径>;'
|
|
457
|
+
+ '② Node ≥ 22.15 时用 NODE_OPTIONS=--use-system-ca 改走系统证书库;'
|
|
458
|
+
+ '③ 让服务端补齐中间证书。';
|
|
459
|
+
}
|
|
460
|
+
if (message === 'fetch failed' && (code || cause?.message)) {
|
|
461
|
+
// 优先用错误码:undici 的 cause.message 往往还是那句 "fetch failed",没有信息量
|
|
462
|
+
const detail = code || cause.message;
|
|
463
|
+
return `连接 "${id}" 失败: ${message} (${detail})`;
|
|
464
|
+
}
|
|
465
|
+
return `连接 "${id}" 失败: ${message}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** SSE 帧 → JSON-RPC 消息。只认 data: 行,event: / id: / 注释行一律忽略。 */
|
|
469
|
+
function parseSseFrame(frame) {
|
|
470
|
+
const data = frame.split(/\r?\n/)
|
|
471
|
+
.filter(line => line.startsWith('data:'))
|
|
472
|
+
.map(line => line.slice(5).trimStart())
|
|
473
|
+
.join('\n');
|
|
474
|
+
if (!data.trim()) return null;
|
|
475
|
+
try { return JSON.parse(data) } catch { return null }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 一个远程 MCP 服务(Streamable HTTP)的连接。
|
|
480
|
+
* 对外接口与 stdio 版完全一致(start / tools / callTool / close / error),
|
|
481
|
+
* 上层 McpManager 不需要知道下面是怎么连的。
|
|
482
|
+
*/
|
|
483
|
+
export class HttpConnection {
|
|
484
|
+
/**
|
|
485
|
+
* @param {string} id - server 标识(配置里的键名)
|
|
486
|
+
* @param {object} config - { url, headers?, disabled? }
|
|
487
|
+
* @param {string} cwd - 只是保持与 stdio 版的构造签名一致(HTTP 没有子进程)
|
|
488
|
+
*/
|
|
489
|
+
constructor(id, config, cwd) {
|
|
490
|
+
this.id = id;
|
|
491
|
+
this.config = config;
|
|
492
|
+
this.cwd = cwd || process.cwd();
|
|
493
|
+
this.tools = [];
|
|
494
|
+
this.error = null;
|
|
495
|
+
// 与 stdio 版同形状:那边把子进程 stderr 留在这里,HTTP 没有对应物
|
|
496
|
+
this.stderr = '';
|
|
497
|
+
this.closed = false;
|
|
498
|
+
this.sessionId = '';
|
|
499
|
+
this.nextId = 1;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
get safeId() { return sanitizeToolName(this.id) }
|
|
503
|
+
|
|
504
|
+
get transport() { return 'http' }
|
|
505
|
+
|
|
506
|
+
get endpoint() { return String(this.config.url || '').trim() }
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 握手(initialize → notifications/initialized → tools/list)。
|
|
510
|
+
* 失败写进 this.error 并返回 false,由 McpManager 汇总,不影响其他 server。
|
|
511
|
+
*/
|
|
512
|
+
async start() {
|
|
513
|
+
try {
|
|
514
|
+
if (!/^https?:\/\//i.test(this.endpoint)) {
|
|
515
|
+
throw new Error(`url 不合法: ${this.endpoint || '(空)'}`);
|
|
516
|
+
}
|
|
517
|
+
this.tools = await handshake({
|
|
518
|
+
id: this.id,
|
|
519
|
+
protocolVersion: HTTP_PROTOCOL_VERSION,
|
|
520
|
+
request: (method, params, timeoutMs) => this.#request(method, params, timeoutMs),
|
|
521
|
+
// 通知失败不拖垮握手(与 stdio 版一个态度),只在后台记账
|
|
522
|
+
notify: (method, params) => { this.#notify(method, params).catch(() => {}) },
|
|
523
|
+
});
|
|
524
|
+
return true;
|
|
525
|
+
} catch (err) {
|
|
526
|
+
this.error = describeHttpError(err, this.id);
|
|
527
|
+
this.closed = true;
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#headers() {
|
|
533
|
+
const headers = {
|
|
534
|
+
'Content-Type': 'application/json',
|
|
535
|
+
// 少写 text/event-stream 会被规范型实现判 406,这是 Streamable HTTP 的硬要求
|
|
536
|
+
'Accept': 'application/json, text/event-stream',
|
|
537
|
+
};
|
|
538
|
+
for (const [key, value] of Object.entries(this.config.headers || {})) {
|
|
539
|
+
if (!key || value === undefined || value === null) continue;
|
|
540
|
+
headers[key] = String(value);
|
|
541
|
+
}
|
|
542
|
+
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
|
|
543
|
+
return headers;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** 发一条 JSON-RPC,返回 result(通知类无响应体时是 null);失败抛已翻译过的 Error。 */
|
|
547
|
+
async #send(payload, timeoutMs) {
|
|
548
|
+
if (this.closed) throw new Error('MCP 连接已关闭');
|
|
549
|
+
const controller = new AbortController();
|
|
550
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
551
|
+
try {
|
|
552
|
+
let response;
|
|
553
|
+
try {
|
|
554
|
+
response = await fetch(this.endpoint, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: this.#headers(),
|
|
557
|
+
body: JSON.stringify(payload),
|
|
558
|
+
signal: controller.signal,
|
|
559
|
+
});
|
|
560
|
+
} catch (err) {
|
|
561
|
+
const failure = err?.name === 'AbortError'
|
|
562
|
+
? new Error(`请求超时(${Math.round(timeoutMs / 1000)}s)`)
|
|
563
|
+
: err;
|
|
564
|
+
throw new Error(describeHttpError(failure, this.id));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const sessionId = response.headers.get('mcp-session-id');
|
|
568
|
+
if (sessionId) this.sessionId = sessionId;
|
|
569
|
+
|
|
570
|
+
if (!response.ok) {
|
|
571
|
+
const body = await response.text().catch(() => '');
|
|
572
|
+
throw new Error(`HTTP ${response.status}${body ? `: ${shortText(body, 300)}` : ''}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const contentType = String(response.headers.get('content-type') || '');
|
|
576
|
+
if (contentType.includes('text/event-stream')) {
|
|
577
|
+
return await this.#readSseResult(response, payload.id);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const text = await response.text();
|
|
581
|
+
if (!text.trim()) return null; // 202 Accepted:通知类请求没有响应体
|
|
582
|
+
let message;
|
|
583
|
+
try { message = JSON.parse(text) } catch {
|
|
584
|
+
throw new Error(`响应不是合法 JSON: ${shortText(text, 200)}`);
|
|
585
|
+
}
|
|
586
|
+
if (message?.error) throw new Error(message.error.message || `MCP error ${message.error.code}`);
|
|
587
|
+
return message?.result ?? null;
|
|
588
|
+
} finally {
|
|
589
|
+
clearTimeout(timer);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 读 SSE 响应,取 id 匹配的那一帧。
|
|
595
|
+
*
|
|
596
|
+
* 用流式 reader 而不是 await response.text():服务端完全可以在返回响应后继续推通知、
|
|
597
|
+
* 把流一直挂着,等整条流读完就会一路挂到超时。拿到目标帧立刻 cancel。
|
|
598
|
+
*/
|
|
599
|
+
async #readSseResult(response, wantedId) {
|
|
600
|
+
const reader = response.body?.getReader?.();
|
|
601
|
+
if (!reader) return null;
|
|
602
|
+
const decoder = new TextDecoder();
|
|
603
|
+
let buffer = '';
|
|
604
|
+
let found = null;
|
|
605
|
+
try {
|
|
606
|
+
while (!found) {
|
|
607
|
+
const { value, done } = await reader.read();
|
|
608
|
+
if (done) break;
|
|
609
|
+
buffer += decoder.decode(value, { stream: true });
|
|
610
|
+
let index;
|
|
611
|
+
while ((index = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
612
|
+
const frame = buffer.slice(0, index);
|
|
613
|
+
buffer = buffer.slice(index).replace(/^\r?\n\r?\n/, '');
|
|
614
|
+
const message = parseSseFrame(frame);
|
|
615
|
+
if (!message) continue;
|
|
616
|
+
// 服务器主动推的通知(没有 id)直接忽略
|
|
617
|
+
if (wantedId === undefined || message.id === wantedId) { found = message; break }
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
} finally {
|
|
621
|
+
reader.cancel().catch(() => {});
|
|
622
|
+
}
|
|
623
|
+
if (!found) return null;
|
|
624
|
+
if (found.error) throw new Error(found.error.message || `MCP error ${found.error.code}`);
|
|
625
|
+
return found.result ?? null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
#request(method, params, timeoutMs) {
|
|
629
|
+
const id = this.nextId++;
|
|
630
|
+
return this.#send({ jsonrpc: '2.0', id, method, params }, timeoutMs);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#notify(method, params) {
|
|
634
|
+
return this.#send({ jsonrpc: '2.0', method, params }, INIT_TIMEOUT_MS);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** 调用一个工具。口径与 stdio 版一致:content 数组拍平成字符串回喂给模型。 */
|
|
638
|
+
async callTool(name, args) {
|
|
639
|
+
const result = await this.#request('tools/call', { name, arguments: args || {} }, CALL_TIMEOUT_MS);
|
|
640
|
+
return flattenToolResult(result);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async close() {
|
|
644
|
+
this.closed = true;
|
|
645
|
+
const url = this.endpoint;
|
|
646
|
+
if (!url || !this.sessionId) return;
|
|
647
|
+
// 规范里结束会话是 DELETE(带 session 头),不是所有服务端都实现 —— 尽力而为
|
|
648
|
+
try {
|
|
649
|
+
await fetch(url, { method: 'DELETE', headers: this.#headers(), signal: AbortSignal.timeout(5000) });
|
|
650
|
+
} catch { /* noop */ }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
363
654
|
// MCP tools/call 返回 { content: [{type:'text',text} | {type:'image',...} | {type:'resource',...}], isError? }
|
|
364
655
|
function flattenToolResult(result) {
|
|
365
656
|
const parts = Array.isArray(result?.content) ? result.content : [];
|
|
@@ -420,7 +711,10 @@ export class McpManager {
|
|
|
420
711
|
const enabled = Object.entries(resolved).filter(([, cfg]) => !cfg.disabled);
|
|
421
712
|
|
|
422
713
|
const settled = await Promise.all(enabled.map(async ([id, cfg]) => {
|
|
423
|
-
|
|
714
|
+
// 有 command → 本地子进程;只有 url → 远程 Streamable HTTP(serverTransport 已判过一遍)
|
|
715
|
+
const connection = serverTransport(cfg) === 'http'
|
|
716
|
+
? new HttpConnection(id, cfg, cwd || process.cwd())
|
|
717
|
+
: new McpConnection(id, cfg, cwd || process.cwd());
|
|
424
718
|
const ok = await connection.start();
|
|
425
719
|
if (!ok && onWarn) onWarn(`MCP "${id}" 连接失败: ${connection.error}`);
|
|
426
720
|
return connection;
|
|
@@ -471,7 +765,9 @@ export class McpManager {
|
|
|
471
765
|
status() {
|
|
472
766
|
return this.connections.map(connection => ({
|
|
473
767
|
id: connection.id,
|
|
474
|
-
|
|
768
|
+
transport: connection.transport,
|
|
769
|
+
// http 型没有 command,退回显示 url —— 渲染端只认这一个字段
|
|
770
|
+
command: connection.config.command || connection.config.url || '',
|
|
475
771
|
tools: connection.tools.length,
|
|
476
772
|
error: connection.error,
|
|
477
773
|
}));
|
|
@@ -496,4 +792,4 @@ export class McpManager {
|
|
|
496
792
|
}
|
|
497
793
|
}
|
|
498
794
|
|
|
499
|
-
export default { McpManager, loadMcpServers, buildSpawnSpec, MCP_TOOL_PREFIX };
|
|
795
|
+
export default { McpManager, HttpConnection, loadMcpServers, buildSpawnSpec, serverTransport, MCP_TOOL_PREFIX };
|