@akasha-book/mcp 1.0.0 → 1.1.2
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 +53 -10
- package/cordis.patch.yml +2 -2
- package/index.js +297 -0
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -1,17 +1,60 @@
|
|
|
1
|
-
# @akasha-book/mcp
|
|
1
|
+
# @akasha-book/mcp —— 阿卡夏之书 MCP 注册桥
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把**阿卡夏之书**(外置记忆层)的工具以 `mcp__akasha__*` 暴露给模型的 DSH 宿主插件。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 它做什么
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- 实现:仅 `package.json` + `cordis.patch.yml`——bundle patch 插入一条 `@deepseek-ai/dsh-mcp-client` 配置,stdio 拉起 `node <AKASHA_DIR>/mcp.mjs`。
|
|
9
|
-
- ⚠️ **安装前请按你的实际路径修改 `cordis.patch.yml` 中的 `command` / `args`**(默认给了占位示例)。
|
|
10
|
-
- 依赖:`@deepseek-ai/dsh-mcp-client`(DSH 官方 bundle,随宿主提供)。
|
|
7
|
+
宿主启动时,本插件:
|
|
11
8
|
|
|
12
|
-
|
|
9
|
+
1. 以 **stdio** 拉起 MCP 服务器(默认 `node <AKASHA_DIR>/mcp.mjs`);
|
|
10
|
+
2. 走 JSON-RPC 握手(`initialize` → `notifications/initialized`);
|
|
11
|
+
3. `tools/list` 取回工具清单,逐个注册到宿主 ToolRuntime,公开名为 **`mcp__akasha__<工具名>`**(与官方桥同名规则:非法字符→`_`;名被改写或超长(>64)→ 截断 + 12 位 sha256 后缀);
|
|
12
|
+
4. `tools/call` 时把参数透传给服务器,并按其契约返回 `{ content, structuredContent? }`;服务器报 `isError` → 抛错(让运行时记为失败)。
|
|
13
|
+
|
|
14
|
+
**自带客户端、零依赖**:本包**不引用任何 `@deepseek-ai/*` 官方包**,也不修改/遮蔽官方组件——这样做是为了满足 DSH-Store 的硬边界(第三方 Bundle Patch 不得以 `name: @deepseek-ai/...` 引用官方包)。
|
|
15
|
+
|
|
16
|
+
## 配置(`cordis.patch.yml`)
|
|
17
|
+
|
|
18
|
+
```yaml
|
|
19
|
+
- insert:
|
|
20
|
+
- id: mcp-akasha
|
|
21
|
+
name: '@akasha-book/mcp'
|
|
22
|
+
config:
|
|
23
|
+
serverName: akasha # 公开名前缀 → mcp__akasha__*
|
|
24
|
+
transport: stdio # 仅支持 stdio
|
|
25
|
+
command: 'node' # 可执行文件(或用绝对路径的 node)
|
|
26
|
+
args: ['<AKASHA_DIR>/mcp.mjs'] # ← 改成你自己的 core/mcp.mjs 绝对路径
|
|
27
|
+
toolCallTimeoutMs: 600000 # 单次工具调用超时(默认 60000)
|
|
28
|
+
startupTimeoutMs: 20000 # 启动握手超时
|
|
29
|
+
failOnStartupError: false # false = 连不上就降级为「无工具」,不拖垮宿主
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 权限 / 非目标 / 边界(permissions · non-goals · boundaries)
|
|
33
|
+
|
|
34
|
+
- **权限**:仅 `spawn` 一个本地子进程(node + `mcp.mjs`),经 stdio 与其通信;不打开网络端口、不读凭据、不写宿主 Profile;工具读写的数据面完全由 MCP 服务器(`core/mcp.mjs`)自己负责。
|
|
35
|
+
- **非目标**:不做 streamable-http 传输;不做图片内容投影(非文本块在**渲染**里降级为 `[type]` 占位符,值仍原样返回、不丢数据);v1 不做自动重连(`failOnStartupError` 决定抛或降级)。
|
|
36
|
+
- **边界**:本插件只负责「连接 + 注册」,**不代替** MCP 服务器本身;服务器崩了工具即失效(日志可见),重启宿主可恢复。
|
|
37
|
+
|
|
38
|
+
## 测试
|
|
13
39
|
|
|
14
40
|
```bash
|
|
15
|
-
|
|
16
|
-
# 然后:plugin_manager install_bundle <tgz 绝对路径>(实测返回 applied 热生效;未热生效时重启宿主)
|
|
41
|
+
node selftest.test.mjs
|
|
17
42
|
```
|
|
43
|
+
|
|
44
|
+
覆盖:Bundle Patch 契约(自有入口 ID、不以 `name` 引用官方命名空间、只 `insert`)、公开名规范化(含超长哈希后缀)、文本提取与非文本降级、**端到端**(临时 core 副本 + 真实 MCP 服务器:注册工具、校验前缀与定义形状、**真调 `akasha_check` 并断言返回**)、malformed JSON-RPC 不导致非零退出。
|
|
45
|
+
|
|
46
|
+
<!-- store-status -->
|
|
47
|
+
## 兼容与状态(DSH STORE 口径 · 2026-10-10)
|
|
48
|
+
|
|
49
|
+
**固定源(immutable source)**:本版本源码冻结于仓库固定 Commit(见 Issue 中登记的 commit;对应 `plugins/mcp-akasha/package.json` 的 version)。
|
|
50
|
+
兼容声明见 manifest 的 `dsh.compatibility.dshReleases`:`0.2.0-rc.2` / `0.2.1-alpha.1` / `0.2.1-alpha.2` 均 **compatible**(三版 × 一次性 Profile 装-启-卸实跑)。
|
|
51
|
+
|
|
52
|
+
| 证据层 | 状态 | 依据 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| 静态契约(manifest / Bundle Patch / 许可证 / 入口 ID) | **verified** | 官方 `build-dsh-plugin` 审计;入口 `mcp-akasha` 为插件自有 ID,Patch 只 `insert` 自有包 |
|
|
55
|
+
| 单元与边界测试(`npm test`) | **verified** | 含端到端:真实 MCP 服务器 + 18 工具注册 + `akasha_check` 实调 |
|
|
56
|
+
| 一次性 Profile 安装·启动·卸载(E3) | **verified** | 三版 DSH 各跑通;证据见同目录 `EVIDENCE.json` |
|
|
57
|
+
| 真实 Profile 运行 | **verified(本机)** | 桌面端 0.2.0-rc.2 实跑;他人机器 **unverified** |
|
|
58
|
+
| 独立安全审计 / 公开分发(E5) | **unverified** | 未做独立审计 |
|
|
59
|
+
|
|
60
|
+
**下一道门(next gate)**:① 在 DSH 上测 streamable-http 传输(若需要);② 若上游暴露公开的注入/工具服务接缝,评估改回"借官方服务"以省掉自研客户端;③ 补图片内容投影。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
- insert:
|
|
2
2
|
- id: mcp-akasha
|
|
3
|
-
name: '@
|
|
3
|
+
name: '@akasha-book/mcp'
|
|
4
4
|
config:
|
|
5
5
|
serverName: akasha
|
|
6
6
|
transport: stdio
|
|
7
|
-
# 按你的实际路径修改:node 可执行文件 + 本包 core/mcp.mjs 的绝对路径(或用 PATH 中的 node)
|
|
8
7
|
command: 'node'
|
|
9
8
|
args: ['<AKASHA_DIR>/mcp.mjs']
|
|
9
|
+
toolCallTimeoutMs: 600000
|
|
10
10
|
failOnStartupError: false
|
package/index.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// @akasha-book/mcp —— 阿卡夏之书 MCP 注册桥(自带 stdio 客户端,**不引用任何 @deepseek-ai/* 包**)
|
|
2
|
+
//
|
|
3
|
+
// 为什么自带:DSH-Store 契约禁止第三方 Bundle Patch 以 `name: @deepseek-ai/...` 引用官方包
|
|
4
|
+
// (`SUBMISSION_PATCH_PROTECTED`),而官方 `@deepseek-ai/dsh-mcp-client` 并未导出"注册一个 MCP 服务器"
|
|
5
|
+
// 的服务。因此本插件自己完成:spawn MCP 服务器(stdio)→ JSON-RPC 握手 → tools/list → 用
|
|
6
|
+
// `ctx.tools.register(def)` 把每个工具注册成 `mcp__<serverName>__<tool>`。
|
|
7
|
+
//
|
|
8
|
+
// 与官方桥的**形状对齐**(照契约自写,未抄其代码):
|
|
9
|
+
// · 公开名规范化:`mcp__${serverName}__${rawName}`,非法字符→`_`;被改写或超长(>64)→ 截断 + 12 位 sha256 后缀;
|
|
10
|
+
// · 工具定义六项:name / description / parameters(=inputSchema) / output{schema, render} / execute;
|
|
11
|
+
// · execute 返回 { content, structuredContent? };MCP `isError` → throw(让运行时记为失败);
|
|
12
|
+
// · 输出 schema:{ type:'object', properties:{ content:{type:'array',items:{}}, structuredContent? }, required, additionalProperties:false }。
|
|
13
|
+
//
|
|
14
|
+
// 已知限制(如实):① 仅支持 stdio;② 文本内容完整保留,图片等非文本块在**渲染**里降级为占位符
|
|
15
|
+
// (值仍原样返回,不丢数据);③ v1 不做自动重连(启动失败按 `failOnStartupError` 决定抛或降级)。
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const name = 'mcp-akasha';
|
|
21
|
+
export const inject = ['tools'];
|
|
22
|
+
|
|
23
|
+
const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
24
|
+
const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
|
|
25
|
+
const HASH_LENGTH = 12;
|
|
26
|
+
const DEFAULTS = {
|
|
27
|
+
transport: 'stdio',
|
|
28
|
+
serverName: 'akasha',
|
|
29
|
+
command: 'node',
|
|
30
|
+
args: [],
|
|
31
|
+
env: {},
|
|
32
|
+
cwd: '',
|
|
33
|
+
toolCallTimeoutMs: 60000,
|
|
34
|
+
startupTimeoutMs: 20000,
|
|
35
|
+
failOnStartupError: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 与官方同名函数的规则一致:必要时加哈希后缀,保证公开名稳定且合法。 */
|
|
39
|
+
export function publicToolName(serverName, rawName) {
|
|
40
|
+
const joined = `mcp__${serverName}__${rawName}`;
|
|
41
|
+
const normalized = joined.replace(INVALID_NAME_CHARS, '_');
|
|
42
|
+
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
|
|
43
|
+
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH);
|
|
44
|
+
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 文本提取:把 MCP content 里的文本块按行拼起来(非文本块给占位符,不丢数据)。 */
|
|
48
|
+
export function renderText(content) {
|
|
49
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
50
|
+
return blocks
|
|
51
|
+
.map((block) => (block && block.type === 'text' ? String(block.text ?? '') : `[${block?.type ?? 'unknown'}]`))
|
|
52
|
+
.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 极简 MCP stdio 客户端(换行分隔 JSON-RPC 2.0)。 */
|
|
56
|
+
export class StdioMcpClient {
|
|
57
|
+
constructor({ command, args = [], env = {}, cwd = '', log = () => {} }) {
|
|
58
|
+
this.command = command;
|
|
59
|
+
this.args = args;
|
|
60
|
+
this.env = env;
|
|
61
|
+
this.cwd = cwd || undefined;
|
|
62
|
+
this.log = log;
|
|
63
|
+
this.child = null;
|
|
64
|
+
this.nextId = 1;
|
|
65
|
+
this.pending = new Map();
|
|
66
|
+
this.buffer = '';
|
|
67
|
+
this.closed = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
start() {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
try {
|
|
74
|
+
this.child = spawn(this.command, this.args, {
|
|
75
|
+
cwd: this.cwd,
|
|
76
|
+
env: { ...process.env, ...this.env },
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
reject(error);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.child.on('error', (error) => {
|
|
84
|
+
if (!settled) { settled = true; reject(error); }
|
|
85
|
+
this.#fail(error);
|
|
86
|
+
});
|
|
87
|
+
this.child.on('exit', (code, signal) => {
|
|
88
|
+
this.closed = true;
|
|
89
|
+
this.#fail(new Error(`MCP server exited (code=${code} signal=${signal ?? 'none'})`));
|
|
90
|
+
});
|
|
91
|
+
this.child.stdout.on('data', (chunk) => this.#onData(chunk));
|
|
92
|
+
this.child.stderr.on('data', (chunk) => this.log('stderr: ' + String(chunk).trim().slice(0, 500)));
|
|
93
|
+
// 关键:子进程死后写 stdin 会以**异步 'error' 事件**抛 EPIPE(try/catch 抓不到同步的),
|
|
94
|
+
// 未处理就是未捕获异常 → 会打死宿主。三条流都挂 error 处理,失败走 #fail(拒绝挂起请求)。
|
|
95
|
+
this.child.stdin.on('error', (error) => this.#fail(error));
|
|
96
|
+
this.child.stdout.on('error', (error) => this.#fail(error));
|
|
97
|
+
this.child.stderr.on('error', () => { /* stderr 出错无需处理 */ });
|
|
98
|
+
this.child.on('spawn', () => { settled = true; resolve(); });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#fail(error) {
|
|
103
|
+
for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(error); }
|
|
104
|
+
this.pending.clear();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#onData(chunk) {
|
|
108
|
+
this.buffer += String(chunk);
|
|
109
|
+
let index;
|
|
110
|
+
while ((index = this.buffer.indexOf('\n')) >= 0) {
|
|
111
|
+
const line = this.buffer.slice(0, index).trim();
|
|
112
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
113
|
+
if (!line) continue;
|
|
114
|
+
let message;
|
|
115
|
+
try { message = JSON.parse(line); } catch { this.log('跳过无法解析的行:' + line.slice(0, 200)); continue; }
|
|
116
|
+
if (message && message.id !== undefined && this.pending.has(message.id)) {
|
|
117
|
+
const { resolve, reject, timer } = this.pending.get(message.id);
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
this.pending.delete(message.id);
|
|
120
|
+
if (message.error) reject(new Error(`MCP ${message.error.code ?? 'error'}: ${message.error.message ?? 'unknown'}`));
|
|
121
|
+
else resolve(message.result);
|
|
122
|
+
} else if (message && message.method) {
|
|
123
|
+
this.log('忽略服务器通知:' + String(message.method));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
request(method, params, timeoutMs) {
|
|
129
|
+
if (this.closed) return Promise.reject(new Error('MCP server is not running'));
|
|
130
|
+
if (!this.child?.stdin?.writable) return Promise.reject(new Error('MCP server stdin is not writable'));
|
|
131
|
+
const id = this.nextId++;
|
|
132
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params: params ?? {} });
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.pending.delete(id);
|
|
136
|
+
reject(new Error(`MCP ${method} 超时(${timeoutMs}ms)`));
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
139
|
+
try { this.child.stdin.write(payload + '\n'); } catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
notify(method, params) {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
try { this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params: params ?? {} }) + '\n'); } catch (error) { this.log('通知写入失败:' + String(error?.message ?? error)); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async initialize(timeoutMs) {
|
|
149
|
+
const result = await this.request('initialize', {
|
|
150
|
+
protocolVersion: '2026-06-18',
|
|
151
|
+
capabilities: {},
|
|
152
|
+
clientInfo: { name: 'akasha-book-mcp', version: '1.1.0' },
|
|
153
|
+
}, timeoutMs);
|
|
154
|
+
this.notify('notifications/initialized', {});
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
listTools(timeoutMs) {
|
|
159
|
+
return this.request('tools/list', {}, timeoutMs);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
callTool(rawName, args, timeoutMs) {
|
|
163
|
+
return this.request('tools/call', { name: rawName, arguments: args ?? {} }, timeoutMs);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
close() {
|
|
167
|
+
this.closed = true;
|
|
168
|
+
this.#fail(new Error('MCP client closed'));
|
|
169
|
+
try { this.child?.kill(); } catch { /* 已退出 */ }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 按官方契约造一个工具定义(六项形状)。 */
|
|
174
|
+
export function createToolDefinition(client, { serverName, tool, toolCallTimeoutMs }) {
|
|
175
|
+
const rawName = tool.name;
|
|
176
|
+
const publicName = publicToolName(serverName, rawName);
|
|
177
|
+
const outputSchema = tool.outputSchema;
|
|
178
|
+
return {
|
|
179
|
+
name: publicName,
|
|
180
|
+
description: tool.description ?? '',
|
|
181
|
+
parameters: tool.inputSchema ?? { type: 'object', properties: {} },
|
|
182
|
+
output: {
|
|
183
|
+
schema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: { content: { type: 'array', items: {} }, ...(outputSchema === undefined ? {} : { structuredContent: outputSchema }) },
|
|
186
|
+
required: outputSchema === undefined ? ['content'] : ['content', 'structuredContent'],
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
},
|
|
189
|
+
render(_args, value) {
|
|
190
|
+
return [{ type: 'text', text: renderText(value?.content) }];
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
async execute(args, exec) {
|
|
194
|
+
const argsObject = args && typeof args === 'object' ? args : {};
|
|
195
|
+
if (exec?.signal?.aborted) throw new Error(`tool ${rawName} 已取消`);
|
|
196
|
+
const call = client.callTool(rawName, argsObject, toolCallTimeoutMs);
|
|
197
|
+
// 尊重调用方取消信号(与官方桥同语义:取消即拒绝,不等超时)
|
|
198
|
+
const result = exec?.signal
|
|
199
|
+
? await Promise.race([
|
|
200
|
+
call,
|
|
201
|
+
new Promise((_resolve, reject) => {
|
|
202
|
+
const onAbort = () => reject(new Error(`tool ${rawName} 已取消`));
|
|
203
|
+
exec.signal.addEventListener('abort', onAbort, { once: true });
|
|
204
|
+
call.finally(() => exec.signal.removeEventListener('abort', onAbort));
|
|
205
|
+
}),
|
|
206
|
+
])
|
|
207
|
+
: await call;
|
|
208
|
+
const text = renderText(result?.content);
|
|
209
|
+
if (result?.isError === true) throw new Error(text || `tool ${rawName} failed`);
|
|
210
|
+
return { content: result?.content ?? [], ...(result?.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }) };
|
|
211
|
+
},
|
|
212
|
+
projectContent() { return undefined; },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function apply(ctx, config = {}) {
|
|
217
|
+
|
|
218
|
+
// A/B 实验开关(2026-10-10 用户解冻,见 docs/ab-experiment-design.md §6):
|
|
219
|
+
// abOff=true ⇒ **关闭臂**——本插件不注册任何守卫/注入/工具,只记一行观测;库文件与日志照旧(便于事后复盘)。
|
|
220
|
+
if (config.abOff === true) {
|
|
221
|
+
try { (ctx.logger?.info ?? (() => {}))('mcp: abOff=true(A/B 关闭臂)——本插件不注册任何面'); } catch { /* 静默 */ }
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const cfg = { ...DEFAULTS, ...(config ?? {}) };
|
|
225
|
+
const log = (message) => { try { ctx.logger?.info?.(`mcp-akasha(${cfg.serverName}): ${message}`); } catch { /* 日志失败不影响主流程 */ } };
|
|
226
|
+
const logError = (message) => { try { ctx.logger?.error?.(`mcp-akasha(${cfg.serverName}): ${message}`); } catch { /* 同上 */ } };
|
|
227
|
+
|
|
228
|
+
if (cfg.transport !== 'stdio') {
|
|
229
|
+
logError(`不支持的 transport=${String(cfg.transport)}(本桥仅 stdio)`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (typeof cfg.command !== 'string' || cfg.command.trim() === '') {
|
|
233
|
+
throw new Error('mcp-akasha: 配置缺失 command(stdio 传输必需)');
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(cfg.args) || cfg.args.length === 0) {
|
|
236
|
+
logError('未配置 args(通常应指向 mcp.mjs 的路径)');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let client = new StdioMcpClient({ command: cfg.command, args: cfg.args, env: cfg.env, cwd: cfg.cwd, log: (m) => log(m) });
|
|
240
|
+
const disposers = new Map();
|
|
241
|
+
const disposeAll = () => { for (const dispose of disposers.values()) { try { dispose(); } catch { /* ignore */ } } disposers.clear(); };
|
|
242
|
+
|
|
243
|
+
const boot = (async () => {
|
|
244
|
+
try {
|
|
245
|
+
await client.start();
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// 2026-10-10(对外安装体验):用户机器上未必有 `node` 在 PATH(DSH 自带运行时)。
|
|
248
|
+
// 若配置里就是 `node` 而启动失败(ENOENT),自动改用**宿主自己的**可执行文件重试一次。
|
|
249
|
+
const looksLikeEnvNode = String(cfg.command).trim().toLowerCase() === 'node';
|
|
250
|
+
const isMissing = String(error?.code ?? '') === 'ENOENT' || /ENOENT/.test(String(error?.message ?? ''));
|
|
251
|
+
if (!looksLikeEnvNode || !isMissing) throw error;
|
|
252
|
+
log(`command='node' 不可用(${String(error?.message ?? error).slice(0, 120)})→ 回退到宿主自带 node:${process.execPath}`);
|
|
253
|
+
client = new StdioMcpClient({ command: process.execPath, args: cfg.args, env: cfg.env, cwd: cfg.cwd, log: (m) => log(m) });
|
|
254
|
+
await client.start();
|
|
255
|
+
}
|
|
256
|
+
const info = await client.initialize(cfg.startupTimeoutMs);
|
|
257
|
+
log(`已连接 ${String(info?.serverInfo?.name ?? 'unknown')} ${String(info?.serverInfo?.version ?? '')}`.trim());
|
|
258
|
+
const listed = await client.listTools(cfg.startupTimeoutMs);
|
|
259
|
+
const tools = Array.isArray(listed?.tools) ? listed.tools : [];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
try {
|
|
262
|
+
for (const tool of tools) {
|
|
263
|
+
const definition = createToolDefinition(client, { serverName: cfg.serverName, tool, toolCallTimeoutMs: cfg.toolCallTimeoutMs });
|
|
264
|
+
if (seen.has(definition.name)) { logError(`服务器重复列出工具 ${definition.name},跳过后续重复项`); continue; }
|
|
265
|
+
seen.add(definition.name);
|
|
266
|
+
disposers.set(definition.name, ctx.tools.register(definition));
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// 注册中途失败:**必须回滚已注册的部分**(否则"半注册"状态与日志不符),再按配置决定抛或降级
|
|
270
|
+
disposeAll();
|
|
271
|
+
throw new Error(`工具注册失败,已回滚全部注册:${String(error?.message ?? error).slice(0, 200)}`);
|
|
272
|
+
}
|
|
273
|
+
log(`已注册 ${disposers.size} 个工具(前缀 mcp__${cfg.serverName}__;清单为启动时快照,重启宿主以刷新)`);
|
|
274
|
+
})();
|
|
275
|
+
|
|
276
|
+
const cleanup = () => { disposeAll(); client.close(); };
|
|
277
|
+
|
|
278
|
+
if (cfg.failOnStartupError === true) {
|
|
279
|
+
// 让 loader 拿到失败(apply 是 async,拒绝即插件的激活失败)——不在 catch 里 throw(那会变成未处理拒绝)
|
|
280
|
+
try {
|
|
281
|
+
await boot;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
cleanup();
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
// 降级路径:立刻挂上 catch(同步挂,避免未处理拒绝);**必须 cleanup**——否则:
|
|
288
|
+
// ① 已注册的工具残留在宿主里("无工具"的日志会说谎);② spawn 成功但握手失败时子进程会泄漏。
|
|
289
|
+
boot.catch((error) => {
|
|
290
|
+
cleanup();
|
|
291
|
+
logError(`启动失败(failOnStartupError=false → 已回滚并终止子进程,降级为无工具):${String(error?.message ?? error).slice(0, 300)}`);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (typeof ctx.effect === 'function') ctx.effect(() => cleanup);
|
|
296
|
+
else if (typeof ctx.on === 'function') { try { ctx.on('dispose', cleanup); } catch { /* 无 dispose 事件 */ } }
|
|
297
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akasha-book/mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=22.15.0"
|
|
6
|
+
},
|
|
5
7
|
"type": "module",
|
|
6
|
-
"description": "阿卡夏之书 MCP
|
|
7
|
-
"
|
|
8
|
+
"description": "阿卡夏之书 MCP 注册桥(自带 stdio 客户端;不引用任何 @deepseek-ai/* 包)",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
8
18
|
"dsh": {
|
|
9
19
|
"bundle": {
|
|
10
20
|
"patch": "./cordis.patch.yml"
|
|
11
21
|
}
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
12
25
|
}
|
|
13
|
-
}
|
|
26
|
+
}
|