@cjhyy/code-shell-server 0.9.8 → 0.9.10
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 +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/sessions-service.d.ts +2 -0
- package/dist/sessions-service.js +8 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { SettingsManager, mergePluginMcpServers } from "@cjhyy/code-shell-core";
|
|
2
|
+
import { computeEffectiveDisabledLists } from "@cjhyy/code-shell-core/internal";
|
|
3
|
+
import { probeHubMcp } from "./mcp-probe.js";
|
|
4
|
+
const ROOT = "/api/v1/mcp";
|
|
5
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
6
|
+
const MAX_SERVERS = 256;
|
|
7
|
+
const FORBIDDEN = new Set(["__proto__", "prototype", "constructor"]);
|
|
8
|
+
export class HubMcpConfigurationError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(status, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function object(value) {
|
|
16
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
17
|
+
}
|
|
18
|
+
function knownFields(value, allowed) {
|
|
19
|
+
if (Object.keys(value).some((key) => FORBIDDEN.has(key) || !allowed.includes(key))) {
|
|
20
|
+
throw new HubMcpConfigurationError(400, "请求包含不支持的配置字段。");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function text(value, field, maximum = 4096, allowEmpty = false) {
|
|
24
|
+
if (typeof value !== "string" ||
|
|
25
|
+
value.length > maximum ||
|
|
26
|
+
(!allowEmpty && !value.trim()) ||
|
|
27
|
+
value.includes("\0")) {
|
|
28
|
+
throw new HubMcpConfigurationError(400, `${field} 格式无效或内容过长。`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function serverName(value) {
|
|
33
|
+
const name = text(value, "服务名称", 128).trim();
|
|
34
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(name) || FORBIDDEN.has(name)) {
|
|
35
|
+
throw new HubMcpConfigurationError(400, "服务名称只能使用字母、数字、点、下划线、短横线和冒号。");
|
|
36
|
+
}
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
function stringList(value, field, maximum = 128) {
|
|
40
|
+
if (!Array.isArray(value) || value.length > maximum)
|
|
41
|
+
throw new HubMcpConfigurationError(400, `${field} 必须是数量有限的文本列表。`);
|
|
42
|
+
return value.map((entry) => text(entry, field, 4096, true));
|
|
43
|
+
}
|
|
44
|
+
function keyName(value, kind) {
|
|
45
|
+
const pattern = kind === "env" ? /^[A-Za-z_][A-Za-z0-9_]*$/ : /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
46
|
+
if (FORBIDDEN.has(value) || value.length > 128 || !pattern.test(value)) {
|
|
47
|
+
throw new HubMcpConfigurationError(400, kind === "env" ? "环境变量名称无效。" : "请求头名称无效。");
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
function secretPatch(value, kind) {
|
|
52
|
+
if (!value ||
|
|
53
|
+
typeof value !== "object" ||
|
|
54
|
+
Array.isArray(value) ||
|
|
55
|
+
Object.keys(value).length > 128) {
|
|
56
|
+
throw new HubMcpConfigurationError(400, "环境变量或请求头必须是数量有限的键值配置。");
|
|
57
|
+
}
|
|
58
|
+
const result = {};
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const [rawKey, rawValue] of Object.entries(value)) {
|
|
61
|
+
const key = keyName(rawKey, kind);
|
|
62
|
+
const canonical = kind === "env" ? key : key.toLowerCase();
|
|
63
|
+
if (seen.has(canonical))
|
|
64
|
+
throw new HubMcpConfigurationError(400, "请求头名称不能重复(不区分大小写)。");
|
|
65
|
+
seen.add(canonical);
|
|
66
|
+
if (rawValue === null)
|
|
67
|
+
result[key] = null;
|
|
68
|
+
else {
|
|
69
|
+
const item = text(rawValue, "配置值", 16_384, true);
|
|
70
|
+
if (kind !== "env" && /[\r\n]/.test(item))
|
|
71
|
+
throw new HubMcpConfigurationError(400, "请求头不能包含换行。");
|
|
72
|
+
if (kind === "envHeaders" && item)
|
|
73
|
+
keyName(item, "env");
|
|
74
|
+
result[key] = item;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
function safeUrl(raw) {
|
|
80
|
+
if (!raw)
|
|
81
|
+
return {};
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(raw);
|
|
84
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
85
|
+
return {};
|
|
86
|
+
const hidden = !!(url.username || url.password || url.search || url.hash);
|
|
87
|
+
url.username = "";
|
|
88
|
+
url.password = "";
|
|
89
|
+
url.search = "";
|
|
90
|
+
url.hash = "";
|
|
91
|
+
return { url: url.toString(), ...(hidden ? { urlHasHiddenParts: true } : {}) };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function inputUrl(value) {
|
|
98
|
+
const raw = text(value, "服务地址");
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(raw);
|
|
101
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash)
|
|
102
|
+
throw new Error();
|
|
103
|
+
return raw;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new HubMcpConfigurationError(400, "请填写 HTTP 或 HTTPS 地址,不要在地址中包含账号、密码或片段。");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function origin(value) {
|
|
110
|
+
try {
|
|
111
|
+
return value ? new URL(value).origin : "";
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function mergePatch(existing, patch, kind, local) {
|
|
118
|
+
const result = { ...local };
|
|
119
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
120
|
+
if (value === "")
|
|
121
|
+
continue; // Blank fields preserve stored values; null is an explicit removal.
|
|
122
|
+
if (kind !== "env") {
|
|
123
|
+
for (const old of new Set([...Object.keys(existing ?? {}), ...Object.keys(result)])) {
|
|
124
|
+
if (old !== key && old.toLowerCase() === key.toLowerCase())
|
|
125
|
+
result[old] = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
result[key] = value;
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
/** Authenticated administrator surface. The host checks its standard Origin policy before dispatch. */
|
|
133
|
+
export function createHubMcpConfiguration(options) {
|
|
134
|
+
const manager = () => new SettingsManager(options.cwd, "full");
|
|
135
|
+
let closed = false;
|
|
136
|
+
const probes = new Map();
|
|
137
|
+
let probeCounter = 0;
|
|
138
|
+
function state() {
|
|
139
|
+
const settings = manager();
|
|
140
|
+
const effective = settings.get();
|
|
141
|
+
const scopes = {
|
|
142
|
+
local: settings.getForScope("local", options.cwd),
|
|
143
|
+
project: settings.getForScope("project", options.cwd),
|
|
144
|
+
user: settings.getForScope("user"),
|
|
145
|
+
};
|
|
146
|
+
const disabled = computeEffectiveDisabledLists(settings, options.cwd);
|
|
147
|
+
const merged = mergePluginMcpServers(effective.mcpServers, [], effective.mcpServerOverrides);
|
|
148
|
+
const sourceScope = (name) => {
|
|
149
|
+
for (const scope of ["local", "project", "user"]) {
|
|
150
|
+
if (Object.hasOwn(scopes[scope].mcpServers ?? {}, name))
|
|
151
|
+
return scope;
|
|
152
|
+
}
|
|
153
|
+
return "managed";
|
|
154
|
+
};
|
|
155
|
+
return { settings, effective, scopes, disabled, merged, sourceScope };
|
|
156
|
+
}
|
|
157
|
+
function snapshot() {
|
|
158
|
+
const current = state();
|
|
159
|
+
const rawLocal = current.settings.getRawForScope("local", options.cwd);
|
|
160
|
+
return {
|
|
161
|
+
workspacePath: options.cwd,
|
|
162
|
+
servers: Object.entries(current.merged)
|
|
163
|
+
.slice(0, MAX_SERVERS)
|
|
164
|
+
.map(([name, config]) => {
|
|
165
|
+
const fromSettings = Object.hasOwn(current.effective.mcpServers, name);
|
|
166
|
+
const pluginDisabled = !fromSettings && current.disabled.disabledPlugins.includes(name.split(":", 1)[0]);
|
|
167
|
+
const transport = config.transport ?? (config.url ? "streamable-http" : "stdio");
|
|
168
|
+
return {
|
|
169
|
+
name,
|
|
170
|
+
source: fromSettings ? "settings" : "plugin",
|
|
171
|
+
scope: fromSettings ? current.sourceScope(name) : "plugin",
|
|
172
|
+
editable: fromSettings && transport !== "inprocess",
|
|
173
|
+
deletable: fromSettings,
|
|
174
|
+
hasLocalOverride: Object.hasOwn(fromSettings
|
|
175
|
+
? (current.scopes.local.mcpServers ?? {})
|
|
176
|
+
: (current.scopes.local.mcpServerOverrides ?? {}), name),
|
|
177
|
+
pluginDisabled,
|
|
178
|
+
enabled: config.enabled !== false && !pluginDisabled,
|
|
179
|
+
transport,
|
|
180
|
+
...(config.command ? { command: config.command.slice(0, 4096) } : {}),
|
|
181
|
+
argsCount: config.args?.length ?? 0,
|
|
182
|
+
...safeUrl(config.url),
|
|
183
|
+
envKeys: Object.keys(config.env ?? {}),
|
|
184
|
+
headerKeys: Object.keys(config.headers ?? {}),
|
|
185
|
+
envVars: config.envVars ?? [],
|
|
186
|
+
...(config.bearerTokenEnvVar ? { bearerTokenEnvVar: config.bearerTokenEnvVar } : {}),
|
|
187
|
+
envHeaders: config.envHeaders ?? {},
|
|
188
|
+
...(config.credentialRef ? { credentialRef: config.credentialRef } : {}),
|
|
189
|
+
...(config.allowedTools ? { allowedTools: config.allowedTools } : {}),
|
|
190
|
+
disabledTools: config.disabledTools ?? [],
|
|
191
|
+
};
|
|
192
|
+
}),
|
|
193
|
+
removed: Object.entries(object(rawLocal.mcpServers))
|
|
194
|
+
.filter(([, value]) => value === null)
|
|
195
|
+
.map(([name]) => ({
|
|
196
|
+
name,
|
|
197
|
+
scope: Object.hasOwn(current.scopes.project.mcpServers ?? {}, name)
|
|
198
|
+
? "project"
|
|
199
|
+
: Object.hasOwn(current.scopes.user.mcpServers ?? {}, name)
|
|
200
|
+
? "user"
|
|
201
|
+
: "managed",
|
|
202
|
+
})),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function cancelServer(name) {
|
|
206
|
+
for (const entry of probes.values())
|
|
207
|
+
if (entry.name === name)
|
|
208
|
+
entry.controller.abort();
|
|
209
|
+
}
|
|
210
|
+
function writeServer(name, input, create) {
|
|
211
|
+
knownFields(input, [
|
|
212
|
+
"name",
|
|
213
|
+
"transport",
|
|
214
|
+
"command",
|
|
215
|
+
"args",
|
|
216
|
+
"url",
|
|
217
|
+
"env",
|
|
218
|
+
"headers",
|
|
219
|
+
"envVars",
|
|
220
|
+
"envHeaders",
|
|
221
|
+
"bearerTokenEnvVar",
|
|
222
|
+
"credentialRef",
|
|
223
|
+
"enabled",
|
|
224
|
+
"allowedTools",
|
|
225
|
+
"disabledTools",
|
|
226
|
+
"reuseStoredSecrets",
|
|
227
|
+
]);
|
|
228
|
+
if (input.name !== undefined && serverName(input.name) !== name)
|
|
229
|
+
throw new HubMcpConfigurationError(400, "编辑服务时不能更换名称,请创建新服务。");
|
|
230
|
+
manager().mutateSettingsForScope("local", options.cwd, (layer) => {
|
|
231
|
+
const current = state();
|
|
232
|
+
const existing = current.merged[name];
|
|
233
|
+
if (create && existing)
|
|
234
|
+
throw new HubMcpConfigurationError(409, "这个服务名称已存在,请使用其他名称。");
|
|
235
|
+
if (!create && !existing)
|
|
236
|
+
throw new HubMcpConfigurationError(404, "服务已不存在,请刷新列表。");
|
|
237
|
+
if (existing && !Object.hasOwn(current.effective.mcpServers, name))
|
|
238
|
+
throw new HubMcpConfigurationError(400, "插件提供的连接由插件管理,不能修改启动命令或地址。");
|
|
239
|
+
if (create && Object.keys(current.merged).length >= MAX_SERVERS)
|
|
240
|
+
throw new HubMcpConfigurationError(400, "MCP 服务数量已达上限。");
|
|
241
|
+
const mode = input.transport ?? existing?.transport ?? (existing?.url ? "streamable-http" : "stdio");
|
|
242
|
+
if (mode !== "stdio" && mode !== "streamable-http" && mode !== "sse")
|
|
243
|
+
throw new HubMcpConfigurationError(400, "请选择本地命令或 HTTP 连接。");
|
|
244
|
+
const local = object(object(layer.mcpServers)[name]);
|
|
245
|
+
const config = { ...local };
|
|
246
|
+
if (create || input.transport !== undefined)
|
|
247
|
+
config.transport = mode;
|
|
248
|
+
if (input.command !== undefined)
|
|
249
|
+
config.command = text(input.command, "启动命令", 4096).trim();
|
|
250
|
+
if (input.args !== undefined)
|
|
251
|
+
config.args = stringList(input.args, "命令参数", 64);
|
|
252
|
+
if (input.url !== undefined)
|
|
253
|
+
config.url = inputUrl(input.url);
|
|
254
|
+
if (mode === "stdio" && !(config.command ?? existing?.command))
|
|
255
|
+
throw new HubMcpConfigurationError(400, "本地命令连接需要填写启动命令。");
|
|
256
|
+
if (mode !== "stdio" && !(config.url ?? existing?.url))
|
|
257
|
+
throw new HubMcpConfigurationError(400, "HTTP 连接需要填写服务地址。");
|
|
258
|
+
if (input.enabled !== undefined) {
|
|
259
|
+
if (typeof input.enabled !== "boolean")
|
|
260
|
+
throw new HubMcpConfigurationError(400, "启用状态必须是布尔值。");
|
|
261
|
+
config.enabled = input.enabled;
|
|
262
|
+
}
|
|
263
|
+
for (const field of ["env", "headers", "envHeaders"]) {
|
|
264
|
+
if (input[field] !== undefined)
|
|
265
|
+
config[field] = mergePatch(existing?.[field], secretPatch(input[field], field), field, object(local[field]));
|
|
266
|
+
}
|
|
267
|
+
for (const field of ["envVars", "allowedTools", "disabledTools"]) {
|
|
268
|
+
if (input[field] !== undefined) {
|
|
269
|
+
config[field] =
|
|
270
|
+
input[field] === null && field === "allowedTools"
|
|
271
|
+
? null
|
|
272
|
+
: stringList(input[field], field);
|
|
273
|
+
if (field === "envVars")
|
|
274
|
+
for (const name of config[field])
|
|
275
|
+
keyName(name, "env");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const field of ["credentialRef", "bearerTokenEnvVar"]) {
|
|
279
|
+
if (input[field] !== undefined) {
|
|
280
|
+
config[field] =
|
|
281
|
+
input[field] === null || input[field] === "" ? null : text(input[field], field, 128);
|
|
282
|
+
if (field === "bearerTokenEnvVar" && config[field])
|
|
283
|
+
keyName(config[field], "env");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (existing) {
|
|
287
|
+
const oldMode = existing.transport ?? (existing.url ? "streamable-http" : "stdio");
|
|
288
|
+
const identityChanged = mode !== oldMode ||
|
|
289
|
+
(mode === "stdio"
|
|
290
|
+
? (config.command ?? existing.command) !== existing.command ||
|
|
291
|
+
(input.args !== undefined &&
|
|
292
|
+
JSON.stringify(input.args) !== JSON.stringify(existing.args ?? []))
|
|
293
|
+
: origin((config.url ?? existing.url)) !== origin(existing.url));
|
|
294
|
+
const sensitive = !!(Object.keys(existing.env ?? {}).length ||
|
|
295
|
+
Object.keys(existing.headers ?? {}).length ||
|
|
296
|
+
existing.envVars?.length ||
|
|
297
|
+
Object.keys(existing.envHeaders ?? {}).length ||
|
|
298
|
+
existing.credentialRef ||
|
|
299
|
+
existing.bearerTokenEnvVar ||
|
|
300
|
+
existing.args?.length);
|
|
301
|
+
if (identityChanged && sensitive && input.reuseStoredSecrets !== true) {
|
|
302
|
+
throw new HubMcpConfigurationError(400, "启动方式或服务域名已改变,请明确确认新连接可以使用已保存的环境变量和认证配置。");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
cancelServer(name);
|
|
306
|
+
layer.mcpServers = { ...object(layer.mcpServers), [name]: config };
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function toggle(name, input) {
|
|
310
|
+
knownFields(input, ["enabled"]);
|
|
311
|
+
if (typeof input.enabled !== "boolean")
|
|
312
|
+
throw new HubMcpConfigurationError(400, "启用状态必须是布尔值。");
|
|
313
|
+
manager().mutateSettingsForScope("local", options.cwd, (layer) => {
|
|
314
|
+
const current = state();
|
|
315
|
+
if (!current.merged[name])
|
|
316
|
+
throw new HubMcpConfigurationError(404, "服务已不存在,请刷新列表。");
|
|
317
|
+
const plugin = !Object.hasOwn(current.effective.mcpServers, name);
|
|
318
|
+
if (plugin &&
|
|
319
|
+
current.disabled.disabledPlugins.includes(name.split(":", 1)[0]) &&
|
|
320
|
+
input.enabled)
|
|
321
|
+
throw new HubMcpConfigurationError(400, "请先启用所属插件,再启用这个工具服务。");
|
|
322
|
+
cancelServer(name);
|
|
323
|
+
const field = plugin ? "mcpServerOverrides" : "mcpServers";
|
|
324
|
+
layer[field] = {
|
|
325
|
+
...object(layer[field]),
|
|
326
|
+
[name]: { ...object(object(layer[field])[name]), enabled: input.enabled },
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
function remove(name, inherit) {
|
|
331
|
+
manager().mutateSettingsForScope("local", options.cwd, (layer) => {
|
|
332
|
+
const current = state();
|
|
333
|
+
const fromSettings = Object.hasOwn(current.effective.mcpServers, name);
|
|
334
|
+
if (!inherit && !fromSettings)
|
|
335
|
+
throw new HubMcpConfigurationError(400, "插件提供的服务不能删除,请停用该服务或移除所属插件。");
|
|
336
|
+
cancelServer(name);
|
|
337
|
+
const field = fromSettings || !current.merged[name] ? "mcpServers" : "mcpServerOverrides";
|
|
338
|
+
const entries = { ...object(layer[field]) };
|
|
339
|
+
if (inherit)
|
|
340
|
+
delete entries[name];
|
|
341
|
+
else if (Object.hasOwn(current.scopes.project.mcpServers ?? {}, name) ||
|
|
342
|
+
Object.hasOwn(current.scopes.user.mcpServers ?? {}, name) ||
|
|
343
|
+
current.sourceScope(name) === "managed")
|
|
344
|
+
entries[name] = null;
|
|
345
|
+
else
|
|
346
|
+
delete entries[name];
|
|
347
|
+
layer[field] = entries;
|
|
348
|
+
// Removing a user-owned collision must not unexpectedly activate the plugin fallback.
|
|
349
|
+
if (!inherit && mergePluginMcpServers({}, [], {})[name]) {
|
|
350
|
+
layer.mcpServerOverrides = {
|
|
351
|
+
...object(layer.mcpServerOverrides),
|
|
352
|
+
[name]: { ...object(object(layer.mcpServerOverrides)[name]), enabled: false },
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async function authorize(req) {
|
|
358
|
+
if (closed)
|
|
359
|
+
throw new HubMcpConfigurationError(503, "服务正在关闭。");
|
|
360
|
+
if (options.isAuthorized && !(await options.isAuthorized(req)))
|
|
361
|
+
throw new HubMcpConfigurationError(401, "登录已失效,请重新登录。");
|
|
362
|
+
if (closed)
|
|
363
|
+
throw new HubMcpConfigurationError(503, "服务正在关闭。");
|
|
364
|
+
}
|
|
365
|
+
async function probe(name, req, res) {
|
|
366
|
+
const current = state();
|
|
367
|
+
const config = current.merged[name];
|
|
368
|
+
if (!config)
|
|
369
|
+
throw new HubMcpConfigurationError(404, "服务已不存在,请刷新列表。");
|
|
370
|
+
if (!Object.hasOwn(current.effective.mcpServers, name) &&
|
|
371
|
+
current.disabled.disabledPlugins.includes(name.split(":", 1)[0]))
|
|
372
|
+
throw new HubMcpConfigurationError(400, "所属插件已停用,请先启用插件再测试连接。");
|
|
373
|
+
const owner = options.ownerId
|
|
374
|
+
? await options.ownerId(req)
|
|
375
|
+
: (req.socket.remoteAddress ?? "administrator");
|
|
376
|
+
if (!owner)
|
|
377
|
+
throw new HubMcpConfigurationError(401, "登录已失效,请重新登录。");
|
|
378
|
+
if (closed)
|
|
379
|
+
throw new HubMcpConfigurationError(503, "服务正在关闭。");
|
|
380
|
+
if (probes.size >= 4 || [...probes.values()].some((entry) => entry.owner === owner))
|
|
381
|
+
throw new HubMcpConfigurationError(429, "已有连接测试正在运行,请等待它完成或取消后重试。");
|
|
382
|
+
const controller = new AbortController();
|
|
383
|
+
const disconnected = () => {
|
|
384
|
+
if (!res.writableEnded)
|
|
385
|
+
controller.abort();
|
|
386
|
+
};
|
|
387
|
+
req.once("aborted", disconnected);
|
|
388
|
+
res.once("close", disconnected);
|
|
389
|
+
req.socket.once("close", disconnected);
|
|
390
|
+
const key = String(++probeCounter);
|
|
391
|
+
const promise = (async () => {
|
|
392
|
+
// Ownership can be revoked while it is being resolved; register before the final check.
|
|
393
|
+
await authorize(req);
|
|
394
|
+
if (controller.signal.aborted)
|
|
395
|
+
throw new HubMcpConfigurationError(409, "已取消连接测试。");
|
|
396
|
+
return probeHubMcp({ ...config, name }, { cwd: options.cwd, signal: controller.signal, timeoutMs: options.probeTimeoutMs });
|
|
397
|
+
})();
|
|
398
|
+
probes.set(key, { owner, name, controller, promise });
|
|
399
|
+
try {
|
|
400
|
+
return await promise;
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
probes.delete(key);
|
|
404
|
+
req.off("aborted", disconnected);
|
|
405
|
+
res.off("close", disconnected);
|
|
406
|
+
req.socket.off("close", disconnected);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function handle(req, res) {
|
|
410
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
411
|
+
if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`))
|
|
412
|
+
return false;
|
|
413
|
+
try {
|
|
414
|
+
if (closed)
|
|
415
|
+
throw new HubMcpConfigurationError(503, "服务正在关闭。");
|
|
416
|
+
if (req.method === "GET" && url.pathname === ROOT) {
|
|
417
|
+
json(res, 200, snapshot());
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
const match = /^\/api\/v1\/mcp\/servers(?:\/([^/]+))?(?:\/(enabled|probe|cancel-probe|inherit))?$/.exec(url.pathname);
|
|
421
|
+
if (!match)
|
|
422
|
+
throw new HubMcpConfigurationError(404, "没有这个 MCP 配置接口。");
|
|
423
|
+
let name;
|
|
424
|
+
try {
|
|
425
|
+
name = match[1] ? serverName(decodeURIComponent(match[1])) : undefined;
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
if (error instanceof HubMcpConfigurationError)
|
|
429
|
+
throw error;
|
|
430
|
+
throw new HubMcpConfigurationError(400, "服务名称无效。");
|
|
431
|
+
}
|
|
432
|
+
const action = match[2];
|
|
433
|
+
const allowed = !name
|
|
434
|
+
? req.method === "POST"
|
|
435
|
+
: action === "probe" || action === "cancel-probe" || action === "inherit"
|
|
436
|
+
? req.method === "POST"
|
|
437
|
+
: action === "enabled"
|
|
438
|
+
? req.method === "PUT"
|
|
439
|
+
: req.method === "PUT" || req.method === "DELETE";
|
|
440
|
+
if (!allowed)
|
|
441
|
+
throw new HubMcpConfigurationError(405, "这个 MCP 配置接口不支持此请求方式。");
|
|
442
|
+
const input = req.method === "DELETE" ? {} : await readJson(req);
|
|
443
|
+
await authorize(req);
|
|
444
|
+
if (action === "cancel-probe" && name) {
|
|
445
|
+
knownFields(input, []);
|
|
446
|
+
const owner = options.ownerId
|
|
447
|
+
? await options.ownerId(req)
|
|
448
|
+
: (req.socket.remoteAddress ?? "administrator");
|
|
449
|
+
if (!owner)
|
|
450
|
+
throw new HubMcpConfigurationError(401, "登录已失效,请重新登录。");
|
|
451
|
+
await authorize(req);
|
|
452
|
+
let cancelled = false;
|
|
453
|
+
for (const entry of probes.values())
|
|
454
|
+
if (entry.name === name && entry.owner === owner) {
|
|
455
|
+
entry.controller.abort();
|
|
456
|
+
cancelled = true;
|
|
457
|
+
}
|
|
458
|
+
json(res, 200, { cancelled });
|
|
459
|
+
}
|
|
460
|
+
else if (action === "probe" && name) {
|
|
461
|
+
knownFields(input, []);
|
|
462
|
+
const result = await probe(name, req, res);
|
|
463
|
+
await authorize(req);
|
|
464
|
+
json(res, 200, result);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
const write = async () => {
|
|
468
|
+
await authorize(req);
|
|
469
|
+
if (!name)
|
|
470
|
+
writeServer(serverName(input.name), input, true);
|
|
471
|
+
else if (action === "enabled")
|
|
472
|
+
toggle(name, input);
|
|
473
|
+
else if (action === "inherit") {
|
|
474
|
+
knownFields(input, []);
|
|
475
|
+
remove(name, true);
|
|
476
|
+
}
|
|
477
|
+
else if (req.method === "DELETE")
|
|
478
|
+
remove(name, false);
|
|
479
|
+
else
|
|
480
|
+
writeServer(name, input, false);
|
|
481
|
+
return snapshot();
|
|
482
|
+
};
|
|
483
|
+
const value = options.withMutation ? await options.withMutation(write) : await write();
|
|
484
|
+
await authorize(req);
|
|
485
|
+
json(res, 200, value);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (cause) {
|
|
489
|
+
const status = cause instanceof HubMcpConfigurationError
|
|
490
|
+
? cause.status
|
|
491
|
+
: typeof cause?.status === "number"
|
|
492
|
+
? cause.status
|
|
493
|
+
: 500;
|
|
494
|
+
const message = cause instanceof HubMcpConfigurationError || [401, 409, 503].includes(status)
|
|
495
|
+
? cause instanceof Error
|
|
496
|
+
? cause.message
|
|
497
|
+
: "请求失败,请重试。"
|
|
498
|
+
: "无法更新 MCP 配置,请检查服务器上的配置文件后重试。";
|
|
499
|
+
json(res, status, { error: message });
|
|
500
|
+
}
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
handle,
|
|
505
|
+
cancelOwner(owner) {
|
|
506
|
+
for (const entry of probes.values())
|
|
507
|
+
if (entry.owner === owner)
|
|
508
|
+
entry.controller.abort();
|
|
509
|
+
},
|
|
510
|
+
async close() {
|
|
511
|
+
closed = true;
|
|
512
|
+
for (const entry of probes.values())
|
|
513
|
+
entry.controller.abort();
|
|
514
|
+
await Promise.allSettled([...probes.values()].map((entry) => entry.promise));
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async function readJson(req) {
|
|
519
|
+
if (req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json")
|
|
520
|
+
throw new HubMcpConfigurationError(415, "请求必须使用 JSON 格式。");
|
|
521
|
+
const declared = Number(req.headers["content-length"] ?? 0);
|
|
522
|
+
if (!Number.isFinite(declared) || declared > MAX_BODY_BYTES)
|
|
523
|
+
throw new HubMcpConfigurationError(413, "配置内容过大。");
|
|
524
|
+
let bytes = 0;
|
|
525
|
+
const chunks = [];
|
|
526
|
+
const timer = setTimeout(() => req.destroy(), 10_000);
|
|
527
|
+
timer.unref();
|
|
528
|
+
try {
|
|
529
|
+
for await (const chunk of req) {
|
|
530
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
531
|
+
bytes += buffer.length;
|
|
532
|
+
if (bytes > MAX_BODY_BYTES)
|
|
533
|
+
throw new HubMcpConfigurationError(413, "配置内容过大。");
|
|
534
|
+
chunks.push(buffer);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
542
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
543
|
+
throw new Error();
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
throw new HubMcpConfigurationError(400, "配置内容不是有效的 JSON 对象。");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function json(res, status, body) {
|
|
551
|
+
if (res.destroyed || res.writableEnded)
|
|
552
|
+
return;
|
|
553
|
+
res.writeHead(status, {
|
|
554
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
555
|
+
"Cache-Control": "no-store",
|
|
556
|
+
});
|
|
557
|
+
res.end(JSON.stringify(body));
|
|
558
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type MCPServerConfig } from "@cjhyy/code-shell-core";
|
|
2
|
+
export interface HubMcpProbeResult {
|
|
3
|
+
name: string;
|
|
4
|
+
status: "ok" | "error" | "cancelled";
|
|
5
|
+
checkedAt: string;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
toolCount?: number;
|
|
8
|
+
truncated?: boolean;
|
|
9
|
+
tools?: {
|
|
10
|
+
name: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
allowed: boolean;
|
|
13
|
+
}[];
|
|
14
|
+
error?: {
|
|
15
|
+
code: string;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Explicit, bounded sibling connection; configuration reads never invoke this function. */
|
|
20
|
+
export declare function probeHubMcp(config: MCPServerConfig, options: {
|
|
21
|
+
cwd: string;
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}): Promise<HubMcpProbeResult>;
|