hob-bot 1.0.4 → 1.1.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/README.md +75 -5
- package/README.zh-CN.md +75 -5
- package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
- package/dist/web/assets/index-B-8ISOr1.js +267 -0
- package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- package/package.json +7 -1
- package/server/src/actions.ts +16 -56
- package/server/src/assistant-skills.ts +201 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +21 -11
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +325 -13
- package/server/src/jobs.ts +280 -41
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +1 -1
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +46 -156
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +39 -5
- package/server/src/router-activity.ts +4 -0
- package/server/src/router-tools.ts +132 -38
- package/server/src/router.ts +153 -17
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/skills.ts +18 -0
- package/shared/types.ts +103 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-BCGEZvgg.css +0 -1
- package/dist/web/assets/index-BqkIAlk9.js +0 -258
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
// MCP 服务配置:读写的目标就是引擎自己读的那份 HOB_HOME/engine/mcp.json(SDK 的 MCP 扩展
|
|
2
|
+
// 在会话启动时连服务)。设置页的所有接口都经过这里;密钥(环境变量 / 请求头的值)只在
|
|
3
|
+
// 本模块和文件之间流动,接口只回「已设置」或不含密钥的整值 ${VAR} / !command 引用。
|
|
4
|
+
import { exec as execCallback } from "node:child_process";
|
|
5
|
+
import { lstatSync, mkdirSync, openSync, closeSync, existsSync, readFileSync, renameSync, rmSync, writeSync, fsyncSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import {
|
|
11
|
+
McpAuthRequiredError,
|
|
12
|
+
McpClient,
|
|
13
|
+
McpHttpError,
|
|
14
|
+
StdioTransport,
|
|
15
|
+
StreamableHttpTransport,
|
|
16
|
+
} from "@earendil-works/pi-mcp";
|
|
17
|
+
import type {
|
|
18
|
+
McpExposureChoice,
|
|
19
|
+
McpImportItem,
|
|
20
|
+
McpImportResult,
|
|
21
|
+
McpImportView,
|
|
22
|
+
McpSecretMutation,
|
|
23
|
+
McpSecretValue,
|
|
24
|
+
McpServerPatch,
|
|
25
|
+
McpServerSave,
|
|
26
|
+
McpServerView,
|
|
27
|
+
McpTestResult,
|
|
28
|
+
McpView,
|
|
29
|
+
} from "../../shared/mcp.ts";
|
|
30
|
+
import { localPiDir } from "./imports.ts";
|
|
31
|
+
|
|
32
|
+
const exec = promisify(execCallback);
|
|
33
|
+
|
|
34
|
+
const MAX_BYTES = 128 * 1024;
|
|
35
|
+
/** 服务名:字母、数字、_、-(和引擎一致;- 和 _ 视为同名)。 */
|
|
36
|
+
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
37
|
+
/** 整值 `${VAR}` 引用(不含密钥,可以原样回显)。 */
|
|
38
|
+
const ENV_REF_RE = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/;
|
|
39
|
+
/** 值里的 `${VAR}` / $VAR 引用(探测和引擎都会展开)。 */
|
|
40
|
+
const ENV_INLINE_RE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
|
|
41
|
+
/** 整值 `!command`(引擎会执行命令取输出)。 */
|
|
42
|
+
const COMMAND_VALUE_RE = /^!.+/;
|
|
43
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
44
|
+
const COMMAND_VALUE_TIMEOUT_MS = 10_000;
|
|
45
|
+
const PROBE_CACHE_TTL_MS = 30_000;
|
|
46
|
+
const PROBE_STDERR_LINES = 8;
|
|
47
|
+
const PROBE_ERROR_MAX = 400;
|
|
48
|
+
const PROBE_CONCURRENCY = 4;
|
|
49
|
+
const CLIENT_INFO = { name: "hob", version: "1.0" };
|
|
50
|
+
|
|
51
|
+
export function mcpConfigPath(home: string): string {
|
|
52
|
+
return join(home, "engine", "mcp.json");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 服务名身份:- 和 _ 是同一个服务(引擎规则),重名按这个比。 */
|
|
56
|
+
export function normalizeMcpName(name: string): string {
|
|
57
|
+
return name.replaceAll("-", "_");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isMcpNameValid(name: string): boolean {
|
|
61
|
+
return NAME_RE.test(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ValidEntry {
|
|
65
|
+
kind: "stdio" | "http";
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
exposure: McpExposureChoice;
|
|
68
|
+
command?: string;
|
|
69
|
+
args?: string[];
|
|
70
|
+
env?: Record<string, string>;
|
|
71
|
+
cwd?: string;
|
|
72
|
+
url?: string;
|
|
73
|
+
headers?: Record<string, string>;
|
|
74
|
+
timeout?: number;
|
|
75
|
+
description?: string;
|
|
76
|
+
toolExposure?: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type Validated = { valid: true; entry: ValidEntry } | { valid: false; reason: string };
|
|
80
|
+
|
|
81
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stringRecord(value: unknown, field: string): Record<string, string> | string {
|
|
86
|
+
if (!isRecord(value)) return `${field} 必须是字符串到字符串的对象`;
|
|
87
|
+
for (const [key, item] of Object.entries(value)) {
|
|
88
|
+
if (typeof item !== "string") return `${field}.${key} 必须是字符串`;
|
|
89
|
+
}
|
|
90
|
+
return value as Record<string, string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 和引擎一致:/sse 结尾是老的 SSE 传输,不受支持。 */
|
|
94
|
+
function sseHint(url: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
return new URL(url).pathname.replace(/\/+$/, "").endsWith("/sse");
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 校验一个 mcpServers 条目(读取和保存共用)。exposure 未写的按「按需搜索」处理:
|
|
104
|
+
* 外部客户端的配置都不写这个字段,而 Hob 的任务会话始终注册按需搜索,行为等价;
|
|
105
|
+
* codemode / hidden 是 Hob 不提供的方式,标记为无效,编辑保存后就成了 direct / deferred。
|
|
106
|
+
*/
|
|
107
|
+
export function validateMcpEntry(name: string, value: unknown): Validated {
|
|
108
|
+
if (!isMcpNameValid(name)) return { valid: false, reason: "名称只能用字母、数字、_ 和 -,最长 64 字符" };
|
|
109
|
+
if (!isRecord(value)) return { valid: false, reason: "条目必须是对象" };
|
|
110
|
+
if (value.type !== undefined) {
|
|
111
|
+
if (value.type === "sse") return { valid: false, reason: "SSE 传输不受支持:请使用 streamable HTTP 地址(通常是 /mcp 结尾)" };
|
|
112
|
+
if (value.type !== "stdio" && value.type !== "http" && value.type !== "streamable-http") {
|
|
113
|
+
return { valid: false, reason: `不认识的类型 ${String(value.type)}` };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const hasCommand = value.command !== undefined;
|
|
117
|
+
const hasUrl = value.url !== undefined;
|
|
118
|
+
if (hasCommand && hasUrl) return { valid: false, reason: "command 和 url 不能同时出现" };
|
|
119
|
+
const kind: "stdio" | "http" | undefined = hasCommand ? "stdio" : hasUrl ? "http" : undefined;
|
|
120
|
+
if (!kind) return { valid: false, reason: "缺少本地命令(stdio)或远程地址(HTTP)" };
|
|
121
|
+
if (value.type !== undefined && value.type !== kind && !(value.type === "streamable-http" && kind === "http")) {
|
|
122
|
+
return { valid: false, reason: `类型 ${String(value.type)} 和字段不匹配` };
|
|
123
|
+
}
|
|
124
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean") return { valid: false, reason: "enabled 必须是布尔值" };
|
|
125
|
+
const exposureRaw = value.exposure;
|
|
126
|
+
let exposure: McpExposureChoice;
|
|
127
|
+
if (exposureRaw === undefined) exposure = "deferred";
|
|
128
|
+
else if (exposureRaw === "direct" || exposureRaw === "deferred") exposure = exposureRaw;
|
|
129
|
+
else return { valid: false, reason: "提供方式只支持 direct(直接提供)或 deferred(按需搜索)" };
|
|
130
|
+
|
|
131
|
+
const entry: ValidEntry = { kind, enabled: value.enabled !== false, exposure };
|
|
132
|
+
if (kind === "stdio") {
|
|
133
|
+
if (typeof value.command !== "string" || !value.command.trim()) return { valid: false, reason: "command 必须是一个可执行文件(不是整条 shell 命令)" };
|
|
134
|
+
entry.command = value.command;
|
|
135
|
+
if (value.args !== undefined) {
|
|
136
|
+
if (!Array.isArray(value.args) || value.args.some((arg) => typeof arg !== "string")) return { valid: false, reason: "args 必须是字符串数组" };
|
|
137
|
+
entry.args = value.args as string[];
|
|
138
|
+
}
|
|
139
|
+
if (value.cwd !== undefined) {
|
|
140
|
+
if (typeof value.cwd !== "string" || !value.cwd.trim()) return { valid: false, reason: "cwd 必须是非空字符串" };
|
|
141
|
+
entry.cwd = value.cwd;
|
|
142
|
+
}
|
|
143
|
+
if (value.env !== undefined) {
|
|
144
|
+
const env = stringRecord(value.env, "env");
|
|
145
|
+
if (typeof env === "string") return { valid: false, reason: env };
|
|
146
|
+
entry.env = env;
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
const url = typeof value.url === "string" ? value.url.trim() : "";
|
|
150
|
+
if (!url) return { valid: false, reason: "url 必须是非空字符串" };
|
|
151
|
+
if (!/^https?:\/\//i.test(url)) return { valid: false, reason: "url 必须以 http(s):// 开头" };
|
|
152
|
+
if (sseHint(url)) return { valid: false, reason: "SSE 传输不受支持:请使用 streamable HTTP 地址(通常是 /mcp 结尾)" };
|
|
153
|
+
entry.url = url;
|
|
154
|
+
if (value.headers !== undefined) {
|
|
155
|
+
const headers = stringRecord(value.headers, "headers");
|
|
156
|
+
if (typeof headers === "string") return { valid: false, reason: headers };
|
|
157
|
+
entry.headers = headers;
|
|
158
|
+
}
|
|
159
|
+
if (value.command !== undefined || value.args !== undefined || value.env !== undefined || value.cwd !== undefined) {
|
|
160
|
+
return { valid: false, reason: "command / args / env / cwd 只适用于本地命令服务" };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (value.timeout !== undefined) {
|
|
164
|
+
if (typeof value.timeout !== "number" || !Number.isSafeInteger(value.timeout) || value.timeout <= 0 || value.timeout > 86_400) {
|
|
165
|
+
return { valid: false, reason: "timeout 必须是正整数(秒)" };
|
|
166
|
+
}
|
|
167
|
+
entry.timeout = value.timeout;
|
|
168
|
+
}
|
|
169
|
+
if (value.description !== undefined) {
|
|
170
|
+
if (typeof value.description !== "string" || value.description.length > 300) return { valid: false, reason: "说明最多 300 字" };
|
|
171
|
+
entry.description = value.description;
|
|
172
|
+
}
|
|
173
|
+
if (value.toolExposure !== undefined) {
|
|
174
|
+
const toolExposure = stringRecord(value.toolExposure, "toolExposure");
|
|
175
|
+
if (typeof toolExposure === "string") return { valid: false, reason: toolExposure };
|
|
176
|
+
entry.toolExposure = toolExposure;
|
|
177
|
+
}
|
|
178
|
+
return { valid: true, entry };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// 文档读写:原子写、新建 0600、只改目标条目、坏文件报错
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
interface McpDoc {
|
|
186
|
+
/** 顶层对象(不含 mcpServers),重写时原样保留 */
|
|
187
|
+
raw: Record<string, unknown>;
|
|
188
|
+
servers: Record<string, unknown>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
class McpFileError extends Error {}
|
|
192
|
+
|
|
193
|
+
function readDoc(path: string): McpDoc {
|
|
194
|
+
let text: string;
|
|
195
|
+
try {
|
|
196
|
+
text = readFileSync(path, "utf8");
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { raw: {}, servers: {} };
|
|
199
|
+
throw new McpFileError("mcp.json 无法读取");
|
|
200
|
+
}
|
|
201
|
+
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) throw new McpFileError("mcp.json 超过大小限制(128KB)");
|
|
202
|
+
let parsed: unknown;
|
|
203
|
+
try {
|
|
204
|
+
parsed = JSON.parse(text);
|
|
205
|
+
} catch {
|
|
206
|
+
// 坏文件不能当成空列表:设置页要能看到错误并修好它
|
|
207
|
+
throw new McpFileError("mcp.json 不是有效的 JSON");
|
|
208
|
+
}
|
|
209
|
+
if (!isRecord(parsed)) throw new McpFileError("mcp.json 必须是 JSON 对象");
|
|
210
|
+
const servers = parsed.mcpServers;
|
|
211
|
+
if (servers !== undefined && !isRecord(servers)) throw new McpFileError("mcp.json 的 mcpServers 必须是对象");
|
|
212
|
+
const { mcpServers: _drop, ...raw } = parsed;
|
|
213
|
+
return { raw, servers: (servers ?? {}) as Record<string, unknown> };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 同目录临时文件 + rename;新建 0600,已存在的文件保持原权限。 */
|
|
217
|
+
function writeDoc(path: string, doc: McpDoc): void {
|
|
218
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
219
|
+
const created = !existsSync(path);
|
|
220
|
+
// Hob 的任务模型不一定写得来脚本调用工具,不开 codemode;新建文件时写明,已有的原样保留
|
|
221
|
+
const payload =
|
|
222
|
+
`${JSON.stringify(
|
|
223
|
+
created ? { autoEnableCodemode: false, ...doc.raw, mcpServers: doc.servers } : { ...doc.raw, mcpServers: doc.servers },
|
|
224
|
+
null,
|
|
225
|
+
2,
|
|
226
|
+
)}\n`;
|
|
227
|
+
if (Buffer.byteLength(payload, "utf8") > MAX_BYTES) throw new McpFileError("mcp.json 超过大小限制(128KB)");
|
|
228
|
+
let mode = 0o600;
|
|
229
|
+
if (!created) {
|
|
230
|
+
const info = lstatSync(path);
|
|
231
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new McpFileError("mcp.json 必须是普通文件");
|
|
232
|
+
mode = info.mode & 0o777;
|
|
233
|
+
}
|
|
234
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
235
|
+
try {
|
|
236
|
+
const fd = openSync(temp, "wx", mode);
|
|
237
|
+
try {
|
|
238
|
+
writeSync(fd, payload);
|
|
239
|
+
fsyncSync(fd);
|
|
240
|
+
} finally {
|
|
241
|
+
closeSync(fd);
|
|
242
|
+
}
|
|
243
|
+
renameSync(temp, path);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
rmSync(temp, { force: true });
|
|
246
|
+
if (error instanceof McpFileError) throw error;
|
|
247
|
+
throw new McpFileError("mcp.json 无法写入");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 写操作串行化:并发修改不会交错写坏文件。 */
|
|
252
|
+
let writeTail: Promise<unknown> = Promise.resolve();
|
|
253
|
+
function runWrite<T>(mutate: () => Promise<T> | T): Promise<T> {
|
|
254
|
+
const run = writeTail.then(mutate, mutate);
|
|
255
|
+
writeTail = run.then(
|
|
256
|
+
() => undefined,
|
|
257
|
+
() => undefined,
|
|
258
|
+
);
|
|
259
|
+
return run;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// 视图(不含密钥)
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function secretValue(value: string): McpSecretValue {
|
|
267
|
+
return ENV_REF_RE.test(value) || COMMAND_VALUE_RE.test(value) ? { redacted: false, value } : { redacted: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function secretRecord(record: Record<string, string> | undefined): Record<string, McpSecretValue> | undefined {
|
|
271
|
+
if (record === undefined) return undefined;
|
|
272
|
+
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, secretValue(value)]));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function serverView(name: string, value: unknown): McpServerView {
|
|
276
|
+
const validated = validateMcpEntry(name, value);
|
|
277
|
+
if (!validated.valid) {
|
|
278
|
+
return { name, valid: false, invalidReason: validated.reason, enabled: isRecord(value) && value.enabled !== false, exposure: "deferred" };
|
|
279
|
+
}
|
|
280
|
+
const entry = validated.entry;
|
|
281
|
+
return {
|
|
282
|
+
name,
|
|
283
|
+
valid: true,
|
|
284
|
+
kind: entry.kind,
|
|
285
|
+
enabled: entry.enabled,
|
|
286
|
+
exposure: entry.exposure,
|
|
287
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
288
|
+
...(entry.kind === "stdio"
|
|
289
|
+
? {
|
|
290
|
+
command: entry.command,
|
|
291
|
+
...(entry.args?.length ? { args: entry.args } : {}),
|
|
292
|
+
...(entry.env ? { env: secretRecord(entry.env) } : {}),
|
|
293
|
+
...(entry.cwd ? { cwd: entry.cwd } : {}),
|
|
294
|
+
}
|
|
295
|
+
: {
|
|
296
|
+
url: entry.url,
|
|
297
|
+
...(entry.headers ? { headers: secretRecord(entry.headers) } : {}),
|
|
298
|
+
}),
|
|
299
|
+
...(entry.toolExposure
|
|
300
|
+
? { hiddenTools: Object.entries(entry.toolExposure).filter(([, v]) => v === "hidden").map(([k]) => k) }
|
|
301
|
+
: {}),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 列表:文件坏了把错误抛给接口(fail-closed,不当成空列表)。 */
|
|
306
|
+
export function mcpView(home: string): McpView {
|
|
307
|
+
const doc = readDoc(mcpConfigPath(home));
|
|
308
|
+
return { servers: Object.entries(doc.servers).map(([name, value]) => serverView(name, value)) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 任务会话要不要加载 MCP:文件里有启用中的服务就加载;文件坏了不加载(错误在设置页能看到)。 */
|
|
312
|
+
export function mcpEnabled(home: string): boolean {
|
|
313
|
+
try {
|
|
314
|
+
const doc = readDoc(mcpConfigPath(home));
|
|
315
|
+
return Object.values(doc.servers).some((value) => !isRecord(value) || value.enabled !== false);
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 任务过程显示用:转义后的服务名 → 配置里的原名(mcp__my_srv__x → my-srv · x)。 */
|
|
322
|
+
export function mcpServerDisplay(home: string): Record<string, string> {
|
|
323
|
+
try {
|
|
324
|
+
const doc = readDoc(mcpConfigPath(home));
|
|
325
|
+
return Object.fromEntries(Object.keys(doc.servers).map((name) => [name.replace(/-/g, "_"), name]));
|
|
326
|
+
} catch {
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// 保存 / 删除 / 补丁
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
function assertName(name: string): void {
|
|
336
|
+
if (!isMcpNameValid(name)) throw new Error("名称只能用字母、数字、_ 和 -,最长 64 字符");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function assertNoConflict(servers: Record<string, unknown>, name: string): void {
|
|
340
|
+
for (const other of Object.keys(servers)) {
|
|
341
|
+
if (other !== name && normalizeMcpName(other) === normalizeMcpName(name)) {
|
|
342
|
+
throw new Error(`和已有服务「${other}」重名(- 和 _ 视为同名)`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** env / headers 逐键套用:保留 / 删除 / 替换。 */
|
|
348
|
+
function applySecretMutations(
|
|
349
|
+
existing: Record<string, string> | undefined,
|
|
350
|
+
mutations: Record<string, McpSecretMutation> | undefined,
|
|
351
|
+
field: string,
|
|
352
|
+
): Record<string, string> {
|
|
353
|
+
const out: Record<string, string> = { ...(existing ?? {}) };
|
|
354
|
+
for (const [key, mutation] of Object.entries(mutations ?? {})) {
|
|
355
|
+
if (!key.trim()) throw new Error(`${field} 的键不能为空`);
|
|
356
|
+
if (mutation.mode === "remove") delete out[key];
|
|
357
|
+
else if (mutation.mode === "preserve") {
|
|
358
|
+
if (existing === undefined || !(key in existing)) throw new Error(`要保留的 ${field}.${key} 不存在`);
|
|
359
|
+
} else out[key] = mutation.value;
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 编辑时保留与服务种类无关的字段(提供方式、工具开关、超时、OAuth 客户端设置)。 */
|
|
365
|
+
function preservedFields(previous: unknown): Record<string, unknown> {
|
|
366
|
+
if (!isRecord(previous)) return {};
|
|
367
|
+
const out: Record<string, unknown> = {};
|
|
368
|
+
for (const key of ["exposure", "toolExposure", "timeout", "oauth"]) {
|
|
369
|
+
if (previous[key] !== undefined) out[key] = previous[key];
|
|
370
|
+
}
|
|
371
|
+
if (previous.enabled === false) out.enabled = false;
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
/** 新建或改一个服务(PUT)。名称是地址参数;重名(-/_)拒绝。 */
|
|
375
|
+
export async function saveMcpServer(home: string, name: string, body: McpServerSave): Promise<McpView> {
|
|
376
|
+
assertName(name);
|
|
377
|
+
if (!body || (body.kind !== "stdio" && body.kind !== "http")) throw new Error("服务种类必须是本地命令(stdio)或远程地址(HTTP)");
|
|
378
|
+
const command = typeof body.command === "string" ? body.command.trim() : undefined;
|
|
379
|
+
const url = typeof body.url === "string" ? body.url.trim() : undefined;
|
|
380
|
+
const args = Array.isArray(body.args) ? body.args.map((arg) => String(arg).trim()).filter(Boolean) : undefined;
|
|
381
|
+
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : undefined;
|
|
382
|
+
const description = typeof body.description === "string" && body.description.trim() ? body.description.trim() : undefined;
|
|
383
|
+
if (body.kind === "stdio" && !command) throw new Error("本地命令服务需要填写命令");
|
|
384
|
+
if (body.kind === "http" && !url) throw new Error("远程地址服务需要填写地址");
|
|
385
|
+
if (body.kind === "http" && sseHint(url!)) throw new Error("SSE 传输不受支持:请使用 streamable HTTP 地址(通常是 /mcp 结尾)");
|
|
386
|
+
|
|
387
|
+
return runWrite(() => {
|
|
388
|
+
const path = mcpConfigPath(home);
|
|
389
|
+
const doc = readDoc(path);
|
|
390
|
+
assertNoConflict(doc.servers, name);
|
|
391
|
+
const previous = doc.servers[name];
|
|
392
|
+
const previousEntry = isRecord(previous) ? validateMcpEntry(name, previous) : null;
|
|
393
|
+
// 上一个条目有效才保留它的额外字段;手改坏的条目按新填的来(提供方式用默认的直接提供)
|
|
394
|
+
const keep = previousEntry?.valid === true ? preservedFields(previous) : {};
|
|
395
|
+
const env = applySecretMutations(
|
|
396
|
+
previousEntry?.valid === true ? previousEntry.entry.env : undefined,
|
|
397
|
+
body.env as Record<string, McpSecretMutation> | undefined,
|
|
398
|
+
"环境变量",
|
|
399
|
+
);
|
|
400
|
+
const headers = applySecretMutations(
|
|
401
|
+
previousEntry?.valid === true ? previousEntry.entry.headers : undefined,
|
|
402
|
+
body.headers as Record<string, McpSecretMutation> | undefined,
|
|
403
|
+
"请求头",
|
|
404
|
+
);
|
|
405
|
+
const entry: Record<string, unknown> = {
|
|
406
|
+
...keep,
|
|
407
|
+
...(body.kind === "stdio" ? { command } : { url }),
|
|
408
|
+
...(body.kind === "stdio" && args?.length ? { args } : {}),
|
|
409
|
+
...(body.kind === "stdio" && Object.keys(env).length ? { env } : {}),
|
|
410
|
+
...(body.kind === "stdio" && cwd ? { cwd } : {}),
|
|
411
|
+
...(body.kind === "http" && Object.keys(headers).length ? { headers } : {}),
|
|
412
|
+
...(description ? { description } : {}),
|
|
413
|
+
// 提供方式始终写明:引擎的默认值是 codemode,Hob 不提供
|
|
414
|
+
exposure: previousEntry?.valid === true ? previousEntry.entry.exposure : "direct",
|
|
415
|
+
};
|
|
416
|
+
const check = validateMcpEntry(name, entry);
|
|
417
|
+
if (!check.valid) throw new Error(check.reason);
|
|
418
|
+
doc.servers = { ...doc.servers, [name]: entry };
|
|
419
|
+
writeDoc(path, doc);
|
|
420
|
+
return mcpView(home);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** 启用 / 停用、提供方式、单独关掉的工具(PATCH)。 */
|
|
425
|
+
export async function patchMcpServer(home: string, name: string, patch: McpServerPatch): Promise<McpView> {
|
|
426
|
+
assertName(name);
|
|
427
|
+
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("enabled 必须是布尔值");
|
|
428
|
+
if (patch.exposure !== undefined && patch.exposure !== "direct" && patch.exposure !== "deferred") {
|
|
429
|
+
throw new Error("提供方式只支持直接提供或按需搜索");
|
|
430
|
+
}
|
|
431
|
+
if (patch.hiddenTools !== undefined && (!Array.isArray(patch.hiddenTools) || patch.hiddenTools.some((t) => typeof t !== "string"))) {
|
|
432
|
+
throw new Error("hiddenTools 必须是字符串数组");
|
|
433
|
+
}
|
|
434
|
+
return runWrite(() => {
|
|
435
|
+
const path = mcpConfigPath(home);
|
|
436
|
+
const doc = readDoc(path);
|
|
437
|
+
const existing = doc.servers[name];
|
|
438
|
+
if (existing === undefined) throw new Error(`没有名为「${name}」的服务`);
|
|
439
|
+
if (!isRecord(existing)) throw new Error(`服务「${name}」的条目无效,请编辑或删除`);
|
|
440
|
+
const next: Record<string, unknown> = { ...existing };
|
|
441
|
+
if (patch.enabled !== undefined) {
|
|
442
|
+
// 引擎默认启用:开启时删掉字段,关闭时写 false
|
|
443
|
+
delete next.enabled;
|
|
444
|
+
if (!patch.enabled) next.enabled = false;
|
|
445
|
+
}
|
|
446
|
+
if (patch.exposure) next.exposure = patch.exposure;
|
|
447
|
+
if (patch.hiddenTools) {
|
|
448
|
+
const toolExposure: Record<string, unknown> = {};
|
|
449
|
+
for (const [tool, value] of Object.entries(isRecord(next.toolExposure) ? next.toolExposure : {})) {
|
|
450
|
+
if (value === "hidden") continue;
|
|
451
|
+
toolExposure[tool] = value;
|
|
452
|
+
}
|
|
453
|
+
for (const tool of patch.hiddenTools) toolExposure[tool] = "hidden";
|
|
454
|
+
if (Object.keys(toolExposure).length) next.toolExposure = toolExposure;
|
|
455
|
+
else delete next.toolExposure;
|
|
456
|
+
}
|
|
457
|
+
const check = validateMcpEntry(name, next);
|
|
458
|
+
if (!check.valid) throw new Error(check.reason);
|
|
459
|
+
doc.servers = { ...doc.servers, [name]: next };
|
|
460
|
+
writeDoc(path, doc);
|
|
461
|
+
return mcpView(home);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 删除一个服务。 */
|
|
466
|
+
export async function removeMcpServer(home: string, name: string): Promise<McpView> {
|
|
467
|
+
assertName(name);
|
|
468
|
+
return runWrite(() => {
|
|
469
|
+
const path = mcpConfigPath(home);
|
|
470
|
+
const doc = readDoc(path);
|
|
471
|
+
if (!(name in doc.servers)) throw new Error(`没有名为「${name}」的服务`);
|
|
472
|
+
const servers = { ...doc.servers };
|
|
473
|
+
delete servers[name];
|
|
474
|
+
doc.servers = servers;
|
|
475
|
+
writeDoc(path, doc);
|
|
476
|
+
return mcpView(home);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// 测试连接(connect → 列工具 → 关闭)
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
function expandHome(value: string): string {
|
|
485
|
+
return value === "~" || value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** 值里的环境变量引用展开(缺失就报清是哪个变量);`!command` 用 shell 跑一次取输出。 */
|
|
489
|
+
async function resolveValue(raw: string, field: string): Promise<string> {
|
|
490
|
+
if (COMMAND_VALUE_RE.test(raw)) {
|
|
491
|
+
try {
|
|
492
|
+
const { stdout } = await exec(raw.slice(1), { timeout: COMMAND_VALUE_TIMEOUT_MS, maxBuffer: 64 * 1024 });
|
|
493
|
+
return stdout.trim();
|
|
494
|
+
} catch {
|
|
495
|
+
throw new Error(`${field} 的命令取值失败`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return raw.replace(ENV_INLINE_RE, (whole, name: string) => {
|
|
499
|
+
const value = process.env[name];
|
|
500
|
+
if (value === undefined || value === "") throw new Error(`${field}:环境变量 ${name} 未设置`);
|
|
501
|
+
return value;
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function truncate(text: string, max: number): string {
|
|
506
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
507
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function raceTimeout<T>(task: Promise<T>, ms: number): Promise<T> {
|
|
511
|
+
return Promise.race([task, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`超时(${Math.round(ms / 1000)} 秒)`)), ms))]);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function gate<T>(task: () => Promise<T>): Promise<T> {
|
|
515
|
+
const wait = () => {
|
|
516
|
+
if (probeActive < PROBE_CONCURRENCY) {
|
|
517
|
+
probeActive += 1;
|
|
518
|
+
return Promise.resolve();
|
|
519
|
+
}
|
|
520
|
+
return new Promise<void>((release) => probeWaiting.push(release));
|
|
521
|
+
};
|
|
522
|
+
return (async () => {
|
|
523
|
+
await wait();
|
|
524
|
+
try {
|
|
525
|
+
return await task();
|
|
526
|
+
} finally {
|
|
527
|
+
probeActive -= 1;
|
|
528
|
+
probeWaiting.shift()?.();
|
|
529
|
+
}
|
|
530
|
+
})();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let probeActive = 0;
|
|
534
|
+
const probeWaiting: Array<() => void> = [];
|
|
535
|
+
|
|
536
|
+
/** 一次探测:连接 → 列工具 → 关闭。stdio 的子进程由 close 收掉(关 stdin 后对整个进程组 SIGTERM/SIGKILL)。 */
|
|
537
|
+
async function probeEntry(entry: ValidEntry): Promise<McpTestResult> {
|
|
538
|
+
let stderrBuffer = "";
|
|
539
|
+
const secrets: string[] = [];
|
|
540
|
+
const resolveSecret = async (raw: string, field: string) => {
|
|
541
|
+
for (const match of raw.matchAll(ENV_INLINE_RE)) {
|
|
542
|
+
const value = process.env[match[1]!];
|
|
543
|
+
if (value) secrets.push(value);
|
|
544
|
+
}
|
|
545
|
+
const value = await resolveValue(raw, field);
|
|
546
|
+
if (value) secrets.push(value);
|
|
547
|
+
return value;
|
|
548
|
+
};
|
|
549
|
+
const redact = (text: string) => secrets.reduce((out, secret) => out.split(secret).join("•••"), text);
|
|
550
|
+
try {
|
|
551
|
+
let transport: StdioTransport | StreamableHttpTransport;
|
|
552
|
+
if (entry.kind === "stdio") {
|
|
553
|
+
const env: Record<string, string> = {};
|
|
554
|
+
for (const [key, raw] of Object.entries(entry.env ?? {})) {
|
|
555
|
+
env[key] = await resolveSecret(raw, `环境变量 ${key}`);
|
|
556
|
+
}
|
|
557
|
+
const cwd = entry.cwd ? resolve(homedir(), expandHome(entry.cwd)) : undefined;
|
|
558
|
+
transport = new StdioTransport({
|
|
559
|
+
command: expandHome(entry.command!),
|
|
560
|
+
...(entry.args?.length ? { args: entry.args.map(expandHome) } : {}),
|
|
561
|
+
...(cwd ? { cwd } : {}),
|
|
562
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
563
|
+
stderr: "pipe",
|
|
564
|
+
onStderr: (chunk) => {
|
|
565
|
+
stderrBuffer = (stderrBuffer + chunk).slice(-16 * 1024);
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
} else {
|
|
569
|
+
const headers: Record<string, string> = {};
|
|
570
|
+
for (const [key, raw] of Object.entries(entry.headers ?? {})) {
|
|
571
|
+
headers[key] = await resolveSecret(raw, `请求头 ${key}`);
|
|
572
|
+
}
|
|
573
|
+
transport = new StreamableHttpTransport({ url: entry.url!, ...(Object.keys(headers).length ? { headers } : {}) });
|
|
574
|
+
}
|
|
575
|
+
const client = new McpClient(CLIENT_INFO);
|
|
576
|
+
try {
|
|
577
|
+
await raceTimeout(client.connect(transport), PROBE_TIMEOUT_MS);
|
|
578
|
+
const listed = await raceTimeout(client.listTools(), PROBE_TIMEOUT_MS);
|
|
579
|
+
return {
|
|
580
|
+
ok: true,
|
|
581
|
+
tools: listed.map((tool) => ({ name: redact(tool.name), description: truncate(redact(tool.description ?? ""), 200) })),
|
|
582
|
+
};
|
|
583
|
+
} finally {
|
|
584
|
+
await raceTimeout(client.close().catch(() => undefined), 5_000);
|
|
585
|
+
}
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const tail = stderrBuffer
|
|
588
|
+
.split(/\r?\n/)
|
|
589
|
+
.map((line) => line.trimEnd())
|
|
590
|
+
.filter((line) => line.trim())
|
|
591
|
+
.slice(-PROBE_STDERR_LINES)
|
|
592
|
+
// 密钥的明文值不能跟着错误信息漏出去
|
|
593
|
+
.map((line) => truncate(redact(line), 200));
|
|
594
|
+
if (
|
|
595
|
+
error instanceof McpAuthRequiredError ||
|
|
596
|
+
(error instanceof McpHttpError && (error.status === 401 || error.status === 403))
|
|
597
|
+
) {
|
|
598
|
+
return { ok: false, state: "needs_auth", error: "服务要求登录,暂不支持", stderrTail: tail };
|
|
599
|
+
}
|
|
600
|
+
const message = error instanceof Error ? error.message : "连接失败";
|
|
601
|
+
return { ok: false, state: "failed", error: truncate(redact(message), PROBE_ERROR_MAX), stderrTail: tail };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
interface ProbeCacheEntry {
|
|
606
|
+
fingerprint: string;
|
|
607
|
+
result: McpTestResult;
|
|
608
|
+
at: number;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const probeCache = new Map<string, ProbeCacheEntry>();
|
|
612
|
+
|
|
613
|
+
/** Worker 会话的工具白名单要加的:MCP 工具全体 + 按需搜索(deferred 的服务靠它加载)。 */
|
|
614
|
+
export const MCP_SESSION_TOOLS = ["mcp__*", "tool_search"];
|
|
615
|
+
|
|
616
|
+
/** 测试一个服务的连接;refresh = false 时 30 秒内的相同配置直接用缓存。 */
|
|
617
|
+
export async function testMcpServer(home: string, name: string, refresh: boolean): Promise<McpTestResult> {
|
|
618
|
+
assertName(name);
|
|
619
|
+
const path = mcpConfigPath(home);
|
|
620
|
+
const doc = readDoc(path);
|
|
621
|
+
const value = doc.servers[name];
|
|
622
|
+
if (value === undefined) throw new Error(`没有名为「${name}」的服务`);
|
|
623
|
+
const validated = validateMcpEntry(name, value);
|
|
624
|
+
if (!validated.valid) throw new Error(validated.reason);
|
|
625
|
+
const fingerprint = JSON.stringify(value);
|
|
626
|
+
const key = `${path}::${name}`;
|
|
627
|
+
const cached = probeCache.get(key);
|
|
628
|
+
if (!refresh && cached && cached.fingerprint === fingerprint && Date.now() - cached.at < PROBE_CACHE_TTL_MS) {
|
|
629
|
+
return cached.result;
|
|
630
|
+
}
|
|
631
|
+
const result = await gate(() => probeEntry(validated.entry));
|
|
632
|
+
probeCache.set(key, { fingerprint, result, at: Date.now() });
|
|
633
|
+
return result;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// 从本机导入(只读来源文件)
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
type ImportSource = "pi" | "claude" | "cursor";
|
|
641
|
+
|
|
642
|
+
const IMPORT_LABEL: Record<ImportSource, string> = { pi: "pi", claude: "Claude Code", cursor: "Cursor" };
|
|
643
|
+
|
|
644
|
+
/** 三个来源的默认位置:pi 用启动时记下的本机目录,其余在用户主目录。 */
|
|
645
|
+
function importSourcePath(source: ImportSource): string {
|
|
646
|
+
if (source === "pi") return join(localPiDir(), "mcp.json");
|
|
647
|
+
if (source === "claude") return join(homedir(), ".claude.json");
|
|
648
|
+
return join(homedir(), ".cursor", "mcp.json");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** 测试注入临时目录用;不传就用默认位置。 */
|
|
652
|
+
export type McpImportDirs = Partial<Record<ImportSource, string>>;
|
|
653
|
+
|
|
654
|
+
interface ImportCandidate {
|
|
655
|
+
item: McpImportItem;
|
|
656
|
+
/** 可导入时有值:要写进 mcp.json 的原条目 */
|
|
657
|
+
raw?: Record<string, unknown>;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function existingNames(home: string): Set<string> {
|
|
661
|
+
try {
|
|
662
|
+
return new Set(Object.keys(readDoc(mcpConfigPath(home)).servers).map(normalizeMcpName));
|
|
663
|
+
} catch {
|
|
664
|
+
// Hob 自己的文件坏了也先列出可导入的:导入时会报错并提示先修文件
|
|
665
|
+
return new Set();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** 三个来源的 mcpServers 读出来(只读;Claude Code 只看顶层 mcpServers)。 */
|
|
670
|
+
function collectImportCandidates(home: string, dirs?: McpImportDirs): { sources: string[]; candidates: ImportCandidate[] } {
|
|
671
|
+
const sources: string[] = [];
|
|
672
|
+
const candidates: ImportCandidate[] = [];
|
|
673
|
+
const existing = existingNames(home);
|
|
674
|
+
for (const source of ["pi", "claude", "cursor"] as ImportSource[]) {
|
|
675
|
+
const path = dirs?.[source] ?? importSourcePath(source);
|
|
676
|
+
if (!existsSync(path)) continue;
|
|
677
|
+
let servers: Record<string, unknown> | undefined;
|
|
678
|
+
try {
|
|
679
|
+
const text = readFileSync(path, "utf8");
|
|
680
|
+
if (Buffer.byteLength(text, "utf8") > MAX_BYTES) continue;
|
|
681
|
+
const parsed: unknown = JSON.parse(text);
|
|
682
|
+
servers = isRecord(parsed) && isRecord(parsed.mcpServers) ? (parsed.mcpServers as Record<string, unknown>) : undefined;
|
|
683
|
+
} catch {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (!servers) continue;
|
|
687
|
+
sources.push(`${IMPORT_LABEL[source]}(${path})`);
|
|
688
|
+
for (const [name, value] of Object.entries(servers)) {
|
|
689
|
+
const validated = validateMcpEntry(name, value);
|
|
690
|
+
const taken = existing.has(normalizeMcpName(name));
|
|
691
|
+
const note = !validated.valid
|
|
692
|
+
? validated.reason
|
|
693
|
+
: taken
|
|
694
|
+
? "已有同名服务,导入会跳过"
|
|
695
|
+
: undefined;
|
|
696
|
+
candidates.push({
|
|
697
|
+
item: {
|
|
698
|
+
id: `${source}:${name}`,
|
|
699
|
+
name,
|
|
700
|
+
source,
|
|
701
|
+
...(validated.valid ? { kind: validated.entry.kind } : {}),
|
|
702
|
+
exists: taken,
|
|
703
|
+
selectable: validated.valid && !taken,
|
|
704
|
+
...(note ? { note } : {}),
|
|
705
|
+
},
|
|
706
|
+
...(validated.valid && isRecord(value) ? { raw: value } : {}),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { sources, candidates };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function listMcpImports(home: string, dirs?: McpImportDirs): McpImportView {
|
|
714
|
+
const { sources, candidates } = collectImportCandidates(home, dirs);
|
|
715
|
+
return { sources, items: candidates.map((candidate) => candidate.item) };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** 导入所选(同名跳过)。导入的条目补上提供方式 direct,其余字段原样复制。 */
|
|
719
|
+
export async function importMcpServers(home: string, ids: string[], dirs?: McpImportDirs): Promise<McpImportResult> {
|
|
720
|
+
const wanted = new Set(ids);
|
|
721
|
+
return runWrite(() => {
|
|
722
|
+
const result: McpImportResult = { imported: [], skipped: [] };
|
|
723
|
+
const path = mcpConfigPath(home);
|
|
724
|
+
const doc = readDoc(path);
|
|
725
|
+
const { candidates } = collectImportCandidates(home, dirs);
|
|
726
|
+
for (const id of wanted) {
|
|
727
|
+
const candidate = candidates.find((item) => item.item.id === id && item.item.selectable);
|
|
728
|
+
if (!candidate) {
|
|
729
|
+
result.skipped.push(`${id}:不可导入(不存在、无效或同名)`);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (Object.keys(doc.servers).some((name) => normalizeMcpName(name) === normalizeMcpName(candidate.item.name))) {
|
|
733
|
+
result.skipped.push(`${candidate.item.name}:已有同名服务,跳过`);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
doc.servers = {
|
|
737
|
+
...doc.servers,
|
|
738
|
+
[candidate.item.name]: { ...(candidate.raw ?? {}), exposure: "direct" },
|
|
739
|
+
};
|
|
740
|
+
result.imported.push(candidate.item.name);
|
|
741
|
+
}
|
|
742
|
+
if (result.imported.length) writeDoc(path, doc);
|
|
743
|
+
return result;
|
|
744
|
+
});
|
|
745
|
+
}
|