dsh-mcp-station 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MARKET.md +9 -0
- package/README.md +9 -0
- package/README.zh.md +14 -0
- package/lib/api.js +366 -4
- package/lib/client.js +1188 -378
- package/lib/patch.js +45 -6
- package/lib/types/api.d.ts +8 -1
- package/lib/types/patch.d.ts +4 -0
- package/lib/types/shared/types.d.ts +42 -0
- package/package.json +1 -1
package/MARKET.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# MCP Station — MARKET
|
|
2
2
|
|
|
3
|
+
npm: https://www.npmjs.com/package/dsh-mcp-station
|
|
4
|
+
GitHub: https://github.com/WilShi/dsh-mcp-station
|
|
5
|
+
|
|
3
6
|
## 一句话
|
|
4
7
|
|
|
5
8
|
DSH 的 MCP 应用商店:精选目录、一键安装、连通性检测、浏览器预设。
|
|
@@ -32,6 +35,12 @@ mcp, mcp-manager, mcp-store, playwright, browser, automation, devtools, one-clic
|
|
|
32
35
|
|
|
33
36
|
## 更新日志
|
|
34
37
|
|
|
38
|
+
### 0.1.1
|
|
39
|
+
|
|
40
|
+
- 自定义 MCP 服务器(stdio/HTTP + headers)
|
|
41
|
+
- Claude/Cursor JSON 配置导入
|
|
42
|
+
- 安装后自动测试 / 一键重试
|
|
43
|
+
|
|
35
44
|
### 0.1.0
|
|
36
45
|
|
|
37
46
|
- 首个 MVP:精选目录、一键安装、连通性检测、Playwright 预设、环境体检。
|
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# MCP Station
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-mcp-station)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
> The MCP app store for DeepSeek Harness: curated catalog, one-click install, health checks and browser presets.
|
|
4
7
|
|
|
5
8
|

|
|
@@ -10,6 +13,12 @@ MCP Station solves three concrete problems for DSH users:
|
|
|
10
13
|
2. **Slow markets**: the default market index goes through jsDelivr and often takes 15-25s or times out in mainland China. The MCP Station catalog is bundled with the plugin and works offline.
|
|
11
14
|
3. **Did it actually work?**: every entry can be tested (initialize + tools/list) with tool count, latency and error details. The Playwright preset detects local Chrome and adds --browser chrome for you.
|
|
12
15
|
|
|
16
|
+
## What's new in 0.1.1
|
|
17
|
+
|
|
18
|
+
- **Custom MCP servers**: stdio (command/args/env) or HTTP (url/headers), with validation and a test button.
|
|
19
|
+
- **Import JSON**: paste Claude / Cursor mcpServers configs or a servers array; invalid or duplicate entries are skipped with reasons.
|
|
20
|
+
- **Auto-test after install**: initialize + tools/list immediately, showing status, tool count and errors; can be disabled in Settings.
|
|
21
|
+
|
|
13
22
|
## Features
|
|
14
23
|
|
|
15
24
|
- Curated store: browser, search, dev, data, memory and productivity categories, bilingual copy and risk labels.
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# MCP Station
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-mcp-station)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
> DSH 的 MCP 应用商店:精选目录、一键安装、连通性检测、浏览器自动化预设。
|
|
4
7
|
|
|
5
8
|

|
|
@@ -10,6 +13,12 @@ MCP Station 是 DeepSeek Harness(DSH)的 MCP 管理插件。它解决三个
|
|
|
10
13
|
2. **市场太慢**:官方插件市场的索引走 jsDelivr,国内经常 15-25 秒甚至超时。MCP Station 的目录随插件打包,离线可用,不依赖任何远程索引。
|
|
11
14
|
3. **装完不知道好不好**:每一项都能「测试连接」,列出工具数量、延迟和错误原因;Playwright 预设会自动检测本机 Chrome 并补上 --browser chrome。
|
|
12
15
|
|
|
16
|
+
## 0.1.1 新增
|
|
17
|
+
|
|
18
|
+
- **自定义 MCP 服务器**:stdio(command/args/env)或 HTTP(url/headers),带表单校验与测试。
|
|
19
|
+
- **从 JSON 导入**:粘贴 Claude / Cursor 的 mcpServers 配置或 servers 数组,一次导入;非法/重名项逐条跳过并给原因。
|
|
20
|
+
- **安装后自动测试**:装完立即 initialize + tools/list,状态、工具数、错误直接显示;设置页可关闭。
|
|
21
|
+
|
|
13
22
|
## 功能
|
|
14
23
|
|
|
15
24
|
- **精选应用商店**:浏览器、搜索、开发、数据、记忆、效率分类;中英文说明与风险标签。
|
|
@@ -61,6 +70,11 @@ MCP Station 是 DeepSeek Harness(DSH)的 MCP 管理插件。它解决三个
|
|
|
61
70
|
- env 值写 env:NAME 时只写入 !!js process.env.NAME,密钥不落盘;secret 输入不回显。
|
|
62
71
|
- 受管块之外的 MCP 配置只读展示,不会被修改。
|
|
63
72
|
|
|
73
|
+
## 更新日志
|
|
74
|
+
|
|
75
|
+
- **0.1.1**:自定义服务器(stdio/HTTP + headers)、Claude/Cursor JSON 导入、安装后自动测试。
|
|
76
|
+
- **0.1.0**:首个 MVP,9 个精选预设、一键安装、连通性检测、Playwright 预设、环境体检。
|
|
77
|
+
|
|
64
78
|
## 兼容性
|
|
65
79
|
|
|
66
80
|
- DSH 0.2.x(web / desktop profile)。
|
package/lib/api.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { findPreset, findPresetForServer, loadCatalog, resolvePresetRow, uniqueServerName, } from "./catalog.js";
|
|
8
8
|
import { callStdioTool, probeHttp, probeStdio, resolveEnvValues } from "./mcp/probe.js";
|
|
9
|
-
import { MANAGED_ID_PREFIX, addManagedRow, installedFromServer, inspectPatchText, isValidServerName, parsePatchRows, readPatchText, removeManagedRow, setManagedRowEnabled, writePatchTextAtomic, } from "./patch.js";
|
|
9
|
+
import { MANAGED_ID_PREFIX, addManagedRow, installedFromServer, inspectPatchText, isValidServerName, parsePatchRows, readPatchText, removeManagedRow, replaceManagedRow, setManagedRowEnabled, writePatchTextAtomic, } from "./patch.js";
|
|
10
10
|
import { resolveStationPaths } from "./paths.js";
|
|
11
11
|
import { detectChrome, detectDshVersion, playwrightBrowserCache } from "./runtime.js";
|
|
12
12
|
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' };
|
|
@@ -122,6 +122,240 @@ function optionalRecord(value, label) {
|
|
|
122
122
|
throw new ApiError(400, 'invalid-request', label + ' 必须是对象');
|
|
123
123
|
return value;
|
|
124
124
|
}
|
|
125
|
+
function optionalBoolean(body, key) {
|
|
126
|
+
const value = body[key];
|
|
127
|
+
if (value === undefined || value === null)
|
|
128
|
+
return undefined;
|
|
129
|
+
if (typeof value !== 'boolean')
|
|
130
|
+
throw new ApiError(400, 'invalid-request', '字段 ' + key + ' 必须是布尔值');
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
function requireTransport(body) {
|
|
134
|
+
const value = body.transport;
|
|
135
|
+
if (value === 'stdio' || value === 'streamable-http')
|
|
136
|
+
return value;
|
|
137
|
+
throw new ApiError(400, 'invalid-request', 'transport 必须是 stdio 或 streamable-http');
|
|
138
|
+
}
|
|
139
|
+
function optionalStringArray(value, label) {
|
|
140
|
+
if (value === undefined || value === null)
|
|
141
|
+
return undefined;
|
|
142
|
+
if (!Array.isArray(value))
|
|
143
|
+
throw new ApiError(400, 'invalid-request', label + ' 必须是字符串数组');
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const item of value) {
|
|
146
|
+
if (typeof item !== 'string')
|
|
147
|
+
throw new ApiError(400, 'invalid-request', label + ' 必须是字符串数组');
|
|
148
|
+
out.push(item);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
function customStringMap(value, label) {
|
|
153
|
+
if (value === undefined || value === null)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!isRecord(value))
|
|
156
|
+
throw new ApiError(400, 'invalid-request', label + ' 必须是对象');
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [key, item] of Object.entries(value)) {
|
|
159
|
+
if (typeof item !== 'string')
|
|
160
|
+
throw new ApiError(400, 'invalid-request', label + '.' + key + ' 必须是字符串');
|
|
161
|
+
if (/^\s*!!js\s+/.test(item)) {
|
|
162
|
+
throw new ApiError(400, 'invalid-request', label + '.' + key + ' 不允许 !!js 表达式(请改用 env:NAME 引用)');
|
|
163
|
+
}
|
|
164
|
+
out[key] = item;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/** Validate one custom server body into a managed row. */
|
|
169
|
+
function parseCustomRow(body, options = {}) {
|
|
170
|
+
const serverName = requireString(body, 'serverName');
|
|
171
|
+
if (!isValidServerName(serverName)) {
|
|
172
|
+
throw new ApiError(400, 'invalid-request', 'serverName 必须是 1-32 位字母、数字、下划线或连字符');
|
|
173
|
+
}
|
|
174
|
+
const transport = requireTransport(body);
|
|
175
|
+
const enabled = body.enabled === undefined ? (options.existing?.enabled ?? true) : requireBoolean(body, 'enabled');
|
|
176
|
+
const row = { serverName, transport, enabled };
|
|
177
|
+
if (transport === 'stdio') {
|
|
178
|
+
const command = optionalString(body, 'command');
|
|
179
|
+
if (command === undefined || command === '') {
|
|
180
|
+
throw new ApiError(400, 'invalid-request', 'stdio 必须提供 command');
|
|
181
|
+
}
|
|
182
|
+
row.command = command;
|
|
183
|
+
const args = optionalStringArray(body.args, 'args');
|
|
184
|
+
if (args !== undefined)
|
|
185
|
+
row.args = args;
|
|
186
|
+
const env = customStringMap(body.env, 'env');
|
|
187
|
+
if (env !== undefined)
|
|
188
|
+
row.env = env;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const url = optionalString(body, 'url');
|
|
192
|
+
if (url === undefined || url === '') {
|
|
193
|
+
throw new ApiError(400, 'invalid-request', 'streamable-http 必须提供 url');
|
|
194
|
+
}
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = new URL(url);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new ApiError(400, 'invalid-request', 'url 不是合法 URL');
|
|
201
|
+
}
|
|
202
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
203
|
+
throw new ApiError(400, 'invalid-request', 'url 只支持 http(s)');
|
|
204
|
+
}
|
|
205
|
+
row.url = url;
|
|
206
|
+
const headers = customStringMap(body.headers, 'headers');
|
|
207
|
+
if (headers !== undefined)
|
|
208
|
+
row.headers = headers;
|
|
209
|
+
}
|
|
210
|
+
return row;
|
|
211
|
+
}
|
|
212
|
+
/** Normalize Claude/Cursor mcpServers objects and servers arrays into candidates. */
|
|
213
|
+
function collectImportCandidates(parsed) {
|
|
214
|
+
const candidates = [];
|
|
215
|
+
const skipped = [];
|
|
216
|
+
const pushArray = (items) => {
|
|
217
|
+
items.forEach((item, index) => {
|
|
218
|
+
if (!isRecord(item)) {
|
|
219
|
+
skipped.push({ name: '#' + String(index + 1), reason: '配置必须是对象' });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const rawName = typeof item.serverName === 'string' ? item.serverName : typeof item.name === 'string' ? item.name : '';
|
|
223
|
+
const name = rawName.trim();
|
|
224
|
+
if (name === '') {
|
|
225
|
+
skipped.push({ name: '#' + String(index + 1), reason: '缺少 serverName' });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
candidates.push({ name, spec: item });
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
if (Array.isArray(parsed)) {
|
|
232
|
+
pushArray(parsed);
|
|
233
|
+
return { candidates, skipped };
|
|
234
|
+
}
|
|
235
|
+
if (!isRecord(parsed))
|
|
236
|
+
throw new ApiError(400, 'invalid-import', 'json 必须是 JSON 对象或数组');
|
|
237
|
+
if (isRecord(parsed.mcpServers)) {
|
|
238
|
+
for (const [name, spec] of Object.entries(parsed.mcpServers)) {
|
|
239
|
+
if (!isRecord(spec)) {
|
|
240
|
+
skipped.push({ name, reason: '配置必须是对象' });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
candidates.push({ name, spec });
|
|
244
|
+
}
|
|
245
|
+
return { candidates, skipped };
|
|
246
|
+
}
|
|
247
|
+
if (Array.isArray(parsed.servers)) {
|
|
248
|
+
pushArray(parsed.servers);
|
|
249
|
+
return { candidates, skipped };
|
|
250
|
+
}
|
|
251
|
+
throw new ApiError(400, 'invalid-import', 'json 里没有 mcpServers 对象或 servers 数组');
|
|
252
|
+
}
|
|
253
|
+
function importStringArray(value) {
|
|
254
|
+
if (value === undefined || value === null)
|
|
255
|
+
return undefined;
|
|
256
|
+
if (!Array.isArray(value))
|
|
257
|
+
return 'invalid';
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const item of value) {
|
|
260
|
+
if (typeof item !== 'string')
|
|
261
|
+
return 'invalid';
|
|
262
|
+
out.push(item);
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
function importStringMap(value) {
|
|
267
|
+
if (value === undefined || value === null)
|
|
268
|
+
return undefined;
|
|
269
|
+
if (!isRecord(value))
|
|
270
|
+
return 'invalid';
|
|
271
|
+
const out = {};
|
|
272
|
+
for (const [key, item] of Object.entries(value)) {
|
|
273
|
+
if (typeof item !== 'string')
|
|
274
|
+
return 'invalid';
|
|
275
|
+
out[key] = item;
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/** Convert one imported config into a managed row, or explain why it was skipped. */
|
|
280
|
+
function importCandidateToRow(candidate) {
|
|
281
|
+
const name = candidate.name;
|
|
282
|
+
if (!isValidServerName(name)) {
|
|
283
|
+
return { reason: 'serverName 不合法(1-32 位字母、数字、下划线或连字符)' };
|
|
284
|
+
}
|
|
285
|
+
const spec = candidate.spec;
|
|
286
|
+
let transport;
|
|
287
|
+
const declared = spec.transport ?? spec.type;
|
|
288
|
+
if (declared !== undefined) {
|
|
289
|
+
if (declared === 'stdio')
|
|
290
|
+
transport = 'stdio';
|
|
291
|
+
else if (declared === 'http' || declared === 'streamable-http')
|
|
292
|
+
transport = 'streamable-http';
|
|
293
|
+
else if (declared === 'sse')
|
|
294
|
+
return { reason: 'mcp-client 不支持 SSE transport' };
|
|
295
|
+
else if (typeof declared === 'string')
|
|
296
|
+
return { reason: '不支持的 transport: ' + declared };
|
|
297
|
+
else
|
|
298
|
+
return { reason: 'transport 必须是字符串' };
|
|
299
|
+
}
|
|
300
|
+
const command = typeof spec.command === 'string' && spec.command.trim() !== '' ? spec.command.trim() : undefined;
|
|
301
|
+
const url = typeof spec.url === 'string' && spec.url.trim() !== '' ? spec.url.trim() : undefined;
|
|
302
|
+
if (transport === undefined) {
|
|
303
|
+
if (command !== undefined)
|
|
304
|
+
transport = 'stdio';
|
|
305
|
+
else if (url !== undefined)
|
|
306
|
+
transport = 'streamable-http';
|
|
307
|
+
else
|
|
308
|
+
return { reason: '缺少 command 或 url' };
|
|
309
|
+
}
|
|
310
|
+
let enabled = true;
|
|
311
|
+
if (typeof spec.enabled === 'boolean')
|
|
312
|
+
enabled = spec.enabled;
|
|
313
|
+
else if (typeof spec.disabled === 'boolean')
|
|
314
|
+
enabled = !spec.disabled;
|
|
315
|
+
if (transport === 'stdio') {
|
|
316
|
+
if (command === undefined)
|
|
317
|
+
return { reason: 'stdio 缺少 command' };
|
|
318
|
+
const args = importStringArray(spec.args);
|
|
319
|
+
if (args === 'invalid')
|
|
320
|
+
return { reason: 'args 必须是字符串数组' };
|
|
321
|
+
const env = importStringMap(spec.env);
|
|
322
|
+
if (env === 'invalid')
|
|
323
|
+
return { reason: 'env 必须是字符串映射' };
|
|
324
|
+
return {
|
|
325
|
+
row: {
|
|
326
|
+
serverName: name,
|
|
327
|
+
transport: 'stdio',
|
|
328
|
+
command,
|
|
329
|
+
...(args === undefined ? {} : { args }),
|
|
330
|
+
...(env === undefined ? {} : { env }),
|
|
331
|
+
enabled,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (url === undefined)
|
|
336
|
+
return { reason: 'streamable-http 缺少 url' };
|
|
337
|
+
let parsedUrl;
|
|
338
|
+
try {
|
|
339
|
+
parsedUrl = new URL(url);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return { reason: 'url 不合法' };
|
|
343
|
+
}
|
|
344
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
|
|
345
|
+
return { reason: 'url 只支持 http(s)' };
|
|
346
|
+
const headers = importStringMap(spec.headers);
|
|
347
|
+
if (headers === 'invalid')
|
|
348
|
+
return { reason: 'headers 必须是字符串映射' };
|
|
349
|
+
return {
|
|
350
|
+
row: {
|
|
351
|
+
serverName: name,
|
|
352
|
+
transport: 'streamable-http',
|
|
353
|
+
url,
|
|
354
|
+
...(headers === undefined ? {} : { headers }),
|
|
355
|
+
enabled,
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
125
359
|
function toProbeResult(outcome) {
|
|
126
360
|
return {
|
|
127
361
|
ok: outcome.ok,
|
|
@@ -138,7 +372,7 @@ function toProbeResult(outcome) {
|
|
|
138
372
|
* @param config - validated plugin configuration.
|
|
139
373
|
* @returns the node:http handler.
|
|
140
374
|
*/
|
|
141
|
-
export function makeApiHandler(_ctx, config) {
|
|
375
|
+
export function makeApiHandler(_ctx, config, dependencies = {}) {
|
|
142
376
|
const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
143
377
|
const probeTimeoutMs = config.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
144
378
|
const probeCache = new Map();
|
|
@@ -187,7 +421,7 @@ export function makeApiHandler(_ctx, config) {
|
|
|
187
421
|
}
|
|
188
422
|
throw new ApiError(404, 'not-found', '找不到 MCP server: ' + serverName);
|
|
189
423
|
}
|
|
190
|
-
async function
|
|
424
|
+
async function probeRowDefault(row) {
|
|
191
425
|
let outcome;
|
|
192
426
|
try {
|
|
193
427
|
if (row.transport === 'streamable-http') {
|
|
@@ -212,6 +446,26 @@ export function makeApiHandler(_ctx, config) {
|
|
|
212
446
|
}
|
|
213
447
|
return toProbeResult(outcome);
|
|
214
448
|
}
|
|
449
|
+
const probeRow = dependencies.probeRow ?? probeRowDefault;
|
|
450
|
+
async function persistManagedRows(paths, next) {
|
|
451
|
+
try {
|
|
452
|
+
await writePatchTextAtomic(paths.patchPath, next, { requireBlock: true });
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
throw new ApiError(500, 'patch-write', '写入 cordis.patch.yml 失败: ' + errorMessage(error));
|
|
456
|
+
}
|
|
457
|
+
const written = await readPatchText(paths.patchPath);
|
|
458
|
+
const check = inspectPatchText(written.text, { requireBlock: true });
|
|
459
|
+
if (!check.ok)
|
|
460
|
+
throw new ApiError(500, 'patch-invalid', '写入后自检失败: ' + (check.error ?? 'unknown'));
|
|
461
|
+
return written.text;
|
|
462
|
+
}
|
|
463
|
+
function storedStationRow(writtenText, serverName) {
|
|
464
|
+
const stored = parsePatchRows(writtenText).find((item) => item.origin === 'station' && item.serverName === serverName);
|
|
465
|
+
if (stored === undefined)
|
|
466
|
+
throw new ApiError(500, 'patch-invalid', '写入后找不到受管行: ' + serverName);
|
|
467
|
+
return stored;
|
|
468
|
+
}
|
|
215
469
|
async function handleStatus() {
|
|
216
470
|
const catalog = await loadCatalogOrFail();
|
|
217
471
|
const paths = resolvePaths();
|
|
@@ -262,10 +516,101 @@ export function makeApiHandler(_ctx, config) {
|
|
|
262
516
|
async function handleServers() {
|
|
263
517
|
return { servers: await listServers() };
|
|
264
518
|
}
|
|
519
|
+
async function handleCustomCreate(body) {
|
|
520
|
+
const catalog = await loadCatalogOrFail();
|
|
521
|
+
const { paths, text, rows } = await readRows();
|
|
522
|
+
const row = parseCustomRow(body);
|
|
523
|
+
const taken = rows.map((item) => item.serverName.toLowerCase());
|
|
524
|
+
if (taken.includes(row.serverName.toLowerCase())) {
|
|
525
|
+
throw new ApiError(409, 'conflict', 'serverName 已存在: ' + row.serverName);
|
|
526
|
+
}
|
|
527
|
+
row.id = MANAGED_ID_PREFIX + row.serverName;
|
|
528
|
+
let next;
|
|
529
|
+
try {
|
|
530
|
+
next = addManagedRow(text, row);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
const message = errorMessage(error);
|
|
534
|
+
if (/已存在|重复/.test(message))
|
|
535
|
+
throw new ApiError(409, 'conflict', message);
|
|
536
|
+
throw new ApiError(500, 'patch-invalid', message);
|
|
537
|
+
}
|
|
538
|
+
const writtenText = await persistManagedRows(paths, next);
|
|
539
|
+
const stored = storedStationRow(writtenText, row.serverName);
|
|
540
|
+
return { server: installedFromServer(stored, catalog) };
|
|
541
|
+
}
|
|
542
|
+
async function handleCustomUpdate(body) {
|
|
543
|
+
const serverName = requireString(body, 'serverName');
|
|
544
|
+
const catalog = await loadCatalogOrFail();
|
|
545
|
+
const { paths, text, rows } = await readRows();
|
|
546
|
+
const station = rows.find((item) => item.origin === 'station' && item.serverName === serverName);
|
|
547
|
+
if (station === undefined) {
|
|
548
|
+
if (rows.some((item) => item.serverName === serverName)) {
|
|
549
|
+
throw new ApiError(403, 'read-only', '外部 MCP 行只读,不能通过 MCP Station 更新: ' + serverName);
|
|
550
|
+
}
|
|
551
|
+
throw new ApiError(404, 'not-found', '找不到 MCP server: ' + serverName);
|
|
552
|
+
}
|
|
553
|
+
const row = parseCustomRow(body, { existing: station });
|
|
554
|
+
row.id = station.id ?? MANAGED_ID_PREFIX + serverName;
|
|
555
|
+
let next;
|
|
556
|
+
try {
|
|
557
|
+
next = replaceManagedRow(text, row);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
throw new ApiError(500, 'patch-invalid', errorMessage(error));
|
|
561
|
+
}
|
|
562
|
+
const writtenText = await persistManagedRows(paths, next);
|
|
563
|
+
const stored = storedStationRow(writtenText, serverName);
|
|
564
|
+
return { server: withRuntime(installedFromServer(stored, catalog), probeCache.get(serverName)) };
|
|
565
|
+
}
|
|
566
|
+
async function handleImport(body) {
|
|
567
|
+
const jsonText = requireString(body, 'json');
|
|
568
|
+
let parsed;
|
|
569
|
+
try {
|
|
570
|
+
parsed = JSON.parse(jsonText);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
throw new ApiError(400, 'invalid-json', 'json 字段不是合法的 JSON 文本');
|
|
574
|
+
}
|
|
575
|
+
const collection = collectImportCandidates(parsed);
|
|
576
|
+
const catalog = await loadCatalogOrFail();
|
|
577
|
+
const { paths, text, rows } = await readRows();
|
|
578
|
+
const taken = new Set(rows.map((item) => item.serverName.toLowerCase()));
|
|
579
|
+
const accepted = [];
|
|
580
|
+
const skipped = [...collection.skipped];
|
|
581
|
+
for (const candidate of collection.candidates) {
|
|
582
|
+
const parsedRow = importCandidateToRow(candidate);
|
|
583
|
+
if (parsedRow.row === undefined) {
|
|
584
|
+
skipped.push({ name: candidate.name, reason: parsedRow.reason ?? '非法配置' });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const key = parsedRow.row.serverName.toLowerCase();
|
|
588
|
+
if (taken.has(key)) {
|
|
589
|
+
skipped.push({ name: candidate.name, reason: '已存在同名 server' });
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
taken.add(key);
|
|
593
|
+
accepted.push(parsedRow.row);
|
|
594
|
+
}
|
|
595
|
+
if (accepted.length === 0)
|
|
596
|
+
return { imported: [], skipped };
|
|
597
|
+
let next = text;
|
|
598
|
+
try {
|
|
599
|
+
for (const row of accepted)
|
|
600
|
+
next = addManagedRow(next, row);
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
throw new ApiError(500, 'patch-invalid', errorMessage(error));
|
|
604
|
+
}
|
|
605
|
+
const writtenText = await persistManagedRows(paths, next);
|
|
606
|
+
const imported = accepted.map((row) => installedFromServer(storedStationRow(writtenText, row.serverName), catalog));
|
|
607
|
+
return { imported, skipped };
|
|
608
|
+
}
|
|
265
609
|
async function handleInstall(body) {
|
|
266
610
|
const presetId = requireString(body, 'presetId');
|
|
267
611
|
const requestedName = optionalString(body, 'serverName');
|
|
268
612
|
const values = stringRecord(body.values, 'values');
|
|
613
|
+
const shouldTest = optionalBoolean(body, 'test') === true;
|
|
269
614
|
const catalog = await loadCatalogOrFail();
|
|
270
615
|
const preset = findPreset(catalog, presetId);
|
|
271
616
|
if (preset === undefined)
|
|
@@ -323,7 +668,12 @@ export function makeApiHandler(_ctx, config) {
|
|
|
323
668
|
const stored = parsePatchRows(written.text).find((item) => item.origin === 'station' && item.serverName === serverName);
|
|
324
669
|
if (stored === undefined)
|
|
325
670
|
throw new ApiError(500, 'patch-invalid', '写入后找不到新行: ' + serverName);
|
|
326
|
-
|
|
671
|
+
const server = installedFromServer(stored, catalog);
|
|
672
|
+
if (!shouldTest)
|
|
673
|
+
return { server };
|
|
674
|
+
const result = await probeRow(stored);
|
|
675
|
+
probeCache.set(serverName, { result, at: Date.now() });
|
|
676
|
+
return { server: withRuntime(server, probeCache.get(serverName)) };
|
|
327
677
|
}
|
|
328
678
|
async function handleTest(body) {
|
|
329
679
|
const serverName = requireString(body, 'serverName');
|
|
@@ -602,6 +952,18 @@ export function makeApiHandler(_ctx, config) {
|
|
|
602
952
|
sendData(res, await handleInstall(body));
|
|
603
953
|
return;
|
|
604
954
|
}
|
|
955
|
+
if (route === '/servers/custom') {
|
|
956
|
+
sendData(res, await handleCustomCreate(body));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (route === '/servers/custom/update') {
|
|
960
|
+
sendData(res, await handleCustomUpdate(body));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (route === '/servers/import') {
|
|
964
|
+
sendData(res, await handleImport(body));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
605
967
|
if (route === '/servers/test') {
|
|
606
968
|
sendData(res, await handleTest(body));
|
|
607
969
|
return;
|