dsh-novel-writer 4.0.1 → 4.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.en.md +25 -7
- package/README.md +93 -13
- package/mcp/README.md +196 -0
- package/mcp/server.mjs +381 -0
- package/package.json +7 -3
package/README.en.md
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
|
-
# 📚 dsh-novel-writer —
|
|
1
|
+
# 📚 dsh-novel-writer — on-device style checkup for novel writers
|
|
2
2
|
|
|
3
|
-
English | [
|
|
3
|
+
English | [中文](./README.md)
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/dsh-novel-writer)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-novel-writer)
|
|
6
7
|
[](LICENSE)
|
|
7
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**16 tools that turn "my writing drifted" into numbers you can act on.**
|
|
12
|
+
Sentence, emotion and style-baseline analysis all run **on your machine**: a 24MB Chinese model ships with the package,
|
|
13
|
+
**zero API cost, your manuscript never leaves the device**. Built for DeepSeek Harness (DSH); the same engine is also
|
|
14
|
+
exposed as a **stdio MCP server** for Claude Desktop / Cursor.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
## Install
|
|
14
17
|
|
|
18
|
+
`sh
|
|
19
|
+
dsh plugin --profile web add dsh-novel-writer
|
|
20
|
+
# or
|
|
21
|
+
npm install dsh-novel-writer
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
Requires **Node >= 22.3**. Restart the web app; a "写作助手功能" panel appears in the sidebar.
|
|
25
|
+
|
|
26
|
+
## MCP server
|
|
27
|
+
|
|
28
|
+
`sh
|
|
29
|
+
node /path/to/node_modules/dsh-novel-writer/mcp/server.mjs --root /path/to/your/novels
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
See [mcp/README.md](./mcp/README.md) for the Claude Desktop / Cursor config.
|
|
15
33
|
|
|
16
34
|
---
|
|
17
35
|
|
package/README.md
CHANGED
|
@@ -1,37 +1,96 @@
|
|
|
1
|
-
# 📚 dsh-novel-writer —
|
|
1
|
+
# 📚 dsh-novel-writer — 给网文作者的本地章节体检
|
|
2
2
|
|
|
3
|
-
[
|
|
3
|
+
[English](./README.en.md) | 中文
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/dsh-novel-writer)
|
|
6
|
+
[](https://www.npmjs.com/package/dsh-novel-writer)
|
|
6
7
|
[](LICENSE)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
7
10
|
[](https://github.com/siweina/dsh-novel-writer/stargazers)
|
|
8
|
-
[](https://github.com/siweina/dsh-novel-writer/releases)
|
|
9
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
**16 个工具,把"文风跑偏"变成可量化的数字。**
|
|
13
|
+
句式 / 情感 / 风格基线全部**在本地算**:24MB 中文模型随包分发,**零 API 花费、正文不出本机**。
|
|
14
|
+
为 DeepSeek Harness(DSH)打造;同一套能力也可作为 **MCP 服务器**给 Claude Desktop / Cursor 使用。
|
|
14
15
|
|
|
16
|
+
[安装](#安装) · [60 秒上手](#60-秒上手) · [看看输出](#看看输出长什么样) · [16 个工具](#提供的工具16-个) · [MCP 服务器](#mcp-服务器非-dsh-用户也能用)
|
|
15
17
|
|
|
16
18
|
---
|
|
17
19
|
|
|
20
|
+
## 它解决什么问题
|
|
21
|
+
|
|
22
|
+
| 你的困扰 | 这里给的答案 |
|
|
23
|
+
|---|---|
|
|
24
|
+
| "我续写的这段,读起来不像我自己写的" | **文笔六维基线**:从句法复杂度 / 修饰密度 / 抽象度 / 动作密度 / 不确定性 / 留白指数六个维度算出原著的 μ±σ,新章逐维对照,出带标 ⚠ |
|
|
25
|
+
| "AI 说文风变了,但说不清哪儿变了" | **风格自检**:相似度 + 偏差清单(哪类句式多了、句长偏了多少、主导情绪有没有换) |
|
|
26
|
+
| "分析小说要花钱调 API" | 语义检索与情感分析**全本地推理**,零 token 花费 |
|
|
27
|
+
| "伏笔埋了忘了收" | **伏笔登记表**:add / list / scan / done,自动记录每条伏笔在哪些章被提到 |
|
|
28
|
+
| "人物设定前后打架" | **设定五张表 + 连贯性审计**(衔接 / OOC / 大纲走偏三件套) |
|
|
29
|
+
| "报告看不懂" | 全是**表格化数字 + 原文锚段**,可以直接截图分享 |
|
|
30
|
+
|
|
18
31
|
## 安装
|
|
19
32
|
|
|
20
33
|
**方式一:npm(推荐)**
|
|
21
34
|
|
|
22
|
-
|
|
35
|
+
`sh
|
|
23
36
|
dsh plugin --profile web add dsh-novel-writer
|
|
24
|
-
|
|
25
|
-
npm install dsh-novel-writer
|
|
26
|
-
```
|
|
37
|
+
`
|
|
27
38
|
|
|
28
39
|
**方式二:从 GitHub 安装**
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
`sh
|
|
31
42
|
dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
**方式三:MCP(不用 DSH 也能用)** —— 见 [MCP 服务器](#mcp-服务器非-dsh-用户也能用)
|
|
46
|
+
|
|
47
|
+
要求 **Node ≥ 22.3**。安装后**重启 Web 应用**,侧边栏出现「写作助手功能」面板。
|
|
48
|
+
|
|
49
|
+
## 60 秒上手
|
|
50
|
+
|
|
51
|
+
`sh
|
|
52
|
+
mkdir -p novels/我的小说 # 把章节文件放进去(第01章.md、第02章.md …)
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
然后在对话里说:**"用 novel_style_report 给我的小说做一次风格画像"**,你会拿到:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
全书 1329 字:六维基线 μ=句法复杂度:2.3 修饰密度:35.6 抽象度:0.5 动作密度:101.7 不确定性:2.1 留白指数:7.0
|
|
59
|
+
推荐容差 25%/35%/100%…
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 看看输出长什么样
|
|
63
|
+
|
|
64
|
+
**风格自检**(新章 vs 全书基线):
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
相似度 0.946 · verdict: high
|
|
68
|
+
偏差清单:心理占比略多 · 对话占比略少 · 短句占比略少 · 主导情绪由 anger 变为 joy
|
|
69
|
+
fixAnchors:3 条原著锚段(对话 / 心理 / 描写各一条,供逐句对照修正)
|
|
32
70
|
```
|
|
33
71
|
|
|
34
|
-
|
|
72
|
+
**语义检索**(自然语言,本地向量):
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
查询「与那盏没有点的灯有关的段落」→
|
|
76
|
+
第02章.md 0.619 对街那盏灯,亮了。
|
|
77
|
+
第01章.md 0.593 阿澈的目光越过老周的肩膀,落在对街那栋小楼上…
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**段落结构**:共 34 段(对话 5 / 心理 0 / 混合 15 / 叙述 14)
|
|
81
|
+
|
|
82
|
+
## 为什么不用在线 AI 写作工具
|
|
83
|
+
|
|
84
|
+
| | 本插件 | 在线 AI 写作工具 | 通用文本分析库 |
|
|
85
|
+
|---|---|---|---|
|
|
86
|
+
| 正文是否离开本机 | **否** | 是 | 视实现 |
|
|
87
|
+
| 花费 | **0(本地推理)** | 按 token 计费 | 自建 |
|
|
88
|
+
| 中文小说专用 | **是** | 通用 | 否 |
|
|
89
|
+
| 风格基线(μ±σ) | **有** | 少见 | 无 |
|
|
90
|
+
| 与 DSH 集成 | **16 工具 + 侧边栏开关** | 无 | 无 |
|
|
91
|
+
| 非 DSH 用户可用 | **可以(MCP)** | 可以 | 需自己封装 |
|
|
92
|
+
|
|
93
|
+
> **致非中文用户**:本插件为中文小说分析写作而设计——句式、情感、意象等核心能力以及内置的语义模型,全部针对中文语料构建与调优。在深耕中文的同时兼顾英文等其他语言,确实超出了我目前的能力范围。若因此给您带来不便,我深感抱歉,恳请谅解。
|
|
35
94
|
|
|
36
95
|
---
|
|
37
96
|
|
|
@@ -75,6 +134,27 @@ dsh plugin --profile web add github:siweina/dsh-novel-writer#main
|
|
|
75
134
|
|
|
76
135
|
---
|
|
77
136
|
|
|
137
|
+
## MCP 服务器(非 DSH 用户也能用)
|
|
138
|
+
|
|
139
|
+
包里自带一个 **stdio MCP 服务器**(`mcp/server.mjs`),把 16 个工具原样暴露给任何 MCP 客户端,
|
|
140
|
+
例如 Claude Desktop、Cursor。**它是跑在你自己电脑上的本地进程,不需要服务器、不需要联网、不需要常驻。**
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"mcpServers": {
|
|
145
|
+
"dsh-novel-writer": {
|
|
146
|
+
"command": "node",
|
|
147
|
+
"args": ["/绝对路径/mcp/server.mjs", "--root", "/你的小说库路径"]
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
书库根目录优先级:`--root` > 环境变量 `DSH_NOVEL_WRITER_ROOT` > 当前工作目录。
|
|
154
|
+
细节见 [mcp/README.md](./mcp/README.md)。
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
78
158
|
## 配置
|
|
79
159
|
|
|
80
160
|
```yaml
|
package/mcp/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# dsh-novel-writer — stdio MCP 服务器
|
|
2
|
+
|
|
3
|
+
把 **dsh-novel-writer** 插件的 16 个 `novel_*` 小说写作工具,以标准 **MCP(Model Context Protocol)stdio 服务器**暴露给
|
|
4
|
+
Claude Desktop、Cursor 等任何 MCP 客户端。
|
|
5
|
+
|
|
6
|
+
- **零业务复制**:服务器不重写任何逻辑,而是用 stub ctx 启动插件(`lib/index.js` 的 `apply()`),捕获它注册的
|
|
7
|
+
`{ name, description, parameters, execute, output }` 工具定义,再把 MCP 的 `tools/list` / `tools/call` 映射过去。
|
|
8
|
+
插件升级后本服务器无需改动。
|
|
9
|
+
- **零外部依赖**:只使用 Node 内置模块,手写 JSON-RPC 2.0(不依赖 `@modelcontextprotocol/sdk`)。
|
|
10
|
+
- **stdout 只走协议帧**,所有日志写 stderr,绝不污染协议通道。
|
|
11
|
+
|
|
12
|
+
要求:Node.js **>= 22.3**(与插件 `engines.node` 一致)。
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. 快速开始
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 直接以 stdio 服务器方式启动(书库根目录用 --root 指定)
|
|
20
|
+
node /abs/path/to/dsh-novel-writer/mcp/server.mjs --root /abs/path/to/novels-workspace
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
服务器启动后从 stdin 读取换行分隔的 JSON-RPC 2.0 请求,向 stdout 写响应。手动冒烟:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
printf '%s\n' \
|
|
27
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}' \
|
|
28
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
|
29
|
+
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"novel_books","arguments":{}}}' \
|
|
30
|
+
| node mcp/server.mjs --root /abs/path/to/novels-workspace
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. 客户端配置
|
|
36
|
+
|
|
37
|
+
### Claude Desktop
|
|
38
|
+
|
|
39
|
+
配置文件位置:
|
|
40
|
+
|
|
41
|
+
- Windows:`%APPDATA%\Claude\claude_desktop_config.json`
|
|
42
|
+
- macOS:`~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
43
|
+
- Linux:`~/.config/Claude/claude_desktop_config.json`
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"dsh-novel-writer": {
|
|
49
|
+
"command": "node",
|
|
50
|
+
"args": [
|
|
51
|
+
"F:/doment/build/dsh-novel-writer-v4.0.0/mcp/server.mjs",
|
|
52
|
+
"--root",
|
|
53
|
+
"F:/doment/novels-workspace"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`args` 里的路径必须是**绝对路径**;Windows 上可用正斜杠 `/`(无需转义),或用双反斜杠 `\\`。
|
|
61
|
+
若 `node` 不在 PATH 中,把 `command` 写成 node 可执行文件的绝对路径(如 `"C:/Program Files/nodejs/node.exe"`)。
|
|
62
|
+
|
|
63
|
+
### Cursor
|
|
64
|
+
|
|
65
|
+
项目级 `.cursor/mcp.json` 或全局 `~/.cursor/mcp.json`:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"dsh-novel-writer": {
|
|
71
|
+
"command": "node",
|
|
72
|
+
"args": [
|
|
73
|
+
"/abs/path/to/dsh-novel-writer/mcp/server.mjs",
|
|
74
|
+
"--root",
|
|
75
|
+
"/abs/path/to/novels-workspace"
|
|
76
|
+
],
|
|
77
|
+
"env": {
|
|
78
|
+
"DSH_NOVEL_WRITER_STATE": "/home/me/.dsh/dsh-novel-writer/state.json"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 通用说明
|
|
86
|
+
|
|
87
|
+
- `--root` 指向**书库根目录**(其下应有 `novels/<书名>/第01章.md` 结构,可先用 `novel_import` 或 DSH 插件创建)。
|
|
88
|
+
- 不写 `--root` 时,依次回退到环境变量 `DSH_NOVEL_WRITER_ROOT`、`process.cwd()`。
|
|
89
|
+
- 客户端配置里的 `env` 会与父进程环境合并后传给服务器,可用于固定状态文件或书库路径。
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 3. 命令行参数与环境变量
|
|
94
|
+
|
|
95
|
+
| 名称 | 类型 | 说明 |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `--root <path>` / `--root=<path>` | 命令行 | 书库根目录。**优先级最高**。 |
|
|
98
|
+
| `DSH_NOVEL_WRITER_ROOT` | 环境变量 | 书库根目录。优先级低于 `--root`,高于 `process.cwd()`。 |
|
|
99
|
+
| `DSH_NOVEL_WRITER_STATE` | 环境变量 | 插件状态文件路径。默认 `~/.dsh/dsh-novel-writer/state.json`。 |
|
|
100
|
+
| `DSH_HOME` | 环境变量 | 插件 UI 配置目录基准(`<DSH_HOME>/novel-writer.json`),默认 `~/.dsh`。 |
|
|
101
|
+
|
|
102
|
+
**root 优先级**:`--root` > `DSH_NOVEL_WRITER_ROOT` > `process.cwd()`。
|
|
103
|
+
|
|
104
|
+
**root 注入规则**:每次 `tools/call` 时,若调用方**未显式**在 `arguments` 中传 `root`(或传空串),服务器会把上面解析出的
|
|
105
|
+
书库根目录注入 `arguments.root`;调用方显式传的 `root` 一律以调用方为准。
|
|
106
|
+
|
|
107
|
+
**状态文件共享**:MCP 服务器与 DSH 插件使用同一个状态文件(`DSH_NOVEL_WRITER_STATE` 可覆盖)。
|
|
108
|
+
因此 DSH 侧边栏「写作助手功能」面板里关闭的工具,在 MCP 侧调用会返回
|
|
109
|
+
`错误:工具 novel_xxx(…)当前已在「写作助手功能」UI 中关闭。…`——用 `novel_sentence_config` 或 GUI 重新开启即可。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 4. 工具清单(16 个)
|
|
114
|
+
|
|
115
|
+
| # | 工具 | 用途 |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| 1 | `novel_books` | 列出书库全部作品(章节数、总字数) |
|
|
118
|
+
| 2 | `novel_chapters` | 列出某作品全部章节(章号/标题/字数/行数/更新时间) |
|
|
119
|
+
| 3 | `novel_read` | 读取章节正文(带行号,可 `offset`/`limit` 分段) |
|
|
120
|
+
| 4 | `novel_keywords` | 高频关键词统计(二字/三字词组与英文词) |
|
|
121
|
+
| 5 | `novel_new_chapter` | 创建新章节文件(自动附风格基线 μ 摘要与原著锚包) |
|
|
122
|
+
| 6 | `novel_import` | 批量导入原稿件(scan 预览 / apply 落盘) |
|
|
123
|
+
| 7 | `novel_sentence_analysis` | 句式模式分析(九类句式、转移、节奏、情感曲线、风格指纹) |
|
|
124
|
+
| 8 | `novel_sentence_config` | 查看/修改功能开关(enabled / autoAnalyze / 各工具开关) |
|
|
125
|
+
| 9 | `novel_style_check` | 章节风格自检(与全书基线对比,输出偏差与锚段) |
|
|
126
|
+
| 10 | `novel_plot` | 伏笔/剧情线登记表(open 待回收 / done 已回收) |
|
|
127
|
+
| 11 | `novel_settings` | 五张设定表(人物/地点/道具/时间线/世界观用语规范) |
|
|
128
|
+
| 12 | `novel_summary` | 章节摘要的增删改查 |
|
|
129
|
+
| 13 | `novel_continuity_check` | 连贯性审计(矛盾候选、衔接、OOC、大纲对照) |
|
|
130
|
+
| 14 | `novel_semantic_search` | 本地语义检索(embedding,无需关键词) |
|
|
131
|
+
| 15 | `novel_style_report` | 风格画像报告(六维基线 + 锚包) |
|
|
132
|
+
| 16 | `novel_outline` | 创作资料维护(创作设定/人物/大纲/钩子/状态卡) |
|
|
133
|
+
|
|
134
|
+
每个工具的 `inputSchema` 就是插件注册时声明的 `parameters`(原样透传,未做任何改写)。
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 5. 协议实现
|
|
139
|
+
|
|
140
|
+
| 方法 | 行为 |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `initialize` | 返回 `protocolVersion: "2024-11-05"`、`capabilities: { tools: {} }`、`serverInfo: { name: "dsh-novel-writer", version: <package.json version> }` |
|
|
143
|
+
| `notifications/initialized` | 通知,无响应(`notifications/cancelled`、`notifications/progress` 等同样静默) |
|
|
144
|
+
| `tools/list` | `{ tools: [{ name, description, inputSchema }] }`,共 16 个 |
|
|
145
|
+
| `tools/call` | 参数 `{ name, arguments }`;成功 → `{ content: [{ type: "text", text }] }` |
|
|
146
|
+
| `ping` | `{}` |
|
|
147
|
+
| 未知方法 | JSON-RPC error `-32601` |
|
|
148
|
+
| 非法 JSON 行 | 记 stderr 后**忽略并继续**,不会退出 |
|
|
149
|
+
| 批量请求(JSON 数组) | 逐条处理并返回响应数组(通知类不产生响应) |
|
|
150
|
+
|
|
151
|
+
**工具执行出错**(含参数校验失败、书目录不存在等)不会中断进程,也不会返回协议级错误,而是:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"content": [{ "type": "text", "text": "错误:<message>" }],
|
|
156
|
+
"isError": true
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
调用不存在的工具名同样返回 `isError: true` 的可读消息(附带工具数提示),避免客户端会话中断。
|
|
161
|
+
|
|
162
|
+
**输出文本规则**:优先使用插件 `output.render(args, value)` 的返回值——仅当其返回**字符串**时采用;
|
|
163
|
+
插件侧 `render` 按 DSH 宿主契约返回 `[{ type: "text", text }]` 数组,此时回退为
|
|
164
|
+
`JSON.stringify(value, null, 2)`,即 MCP 客户端收到的是结构化 JSON 文本。
|
|
165
|
+
|
|
166
|
+
**通道纪律**:stdout 只写 JSON-RPC 帧;启动信息、工具错误堆栈、非法行告警等**全部写 stderr**。
|
|
167
|
+
服务器还会把插件内部的 `console.log/info/debug` 改道 stderr 作为兜底保护。
|
|
168
|
+
|
|
169
|
+
**生命周期**:stdin 关闭 → 等待在途请求完成并冲刷 stdout → `exit 0`;5 秒兜底强制退出。
|
|
170
|
+
`unhandledRejection` 只记日志不退出,`uncaughtException` 记日志后退出 1。
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## 6. 测试
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
node --check mcp/server.mjs # 语法检查
|
|
178
|
+
node test/mcp-test.mjs # 33 项协议/工具断言,exit=0 为通过
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`test/mcp-test.mjs` 会在系统临时目录里现造一本 2 章的书,`spawn` 真实服务器进程,覆盖:
|
|
182
|
+
握手与版本、通知无响应、16 工具清单与 schema 透传、`novel_books`/`novel_read`/`novel_sentence_config` 真实调用、
|
|
183
|
+
root 注入与显式 root 优先、工具抛错隔离、未知工具/未知方法、非法 JSON 行容错、stdout 纯净性、stdin 关闭后 exit=0。
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 7. 排障
|
|
188
|
+
|
|
189
|
+
| 现象 | 处理 |
|
|
190
|
+
|---|---|
|
|
191
|
+
| 工具返回空书库 | 用 `novel_books` 看返回的 `root` 字段是否为目标书库;检查 `--root` 是否写成了 `novels/` 子目录。 |
|
|
192
|
+
| `错误:工具 … 当前已在「写作助手功能」UI 中关闭` | 用 `novel_sentence_config` 重新开启,或在 DSH 侧边栏面板打开。 |
|
|
193
|
+
| 首次语义检索/风格分析较慢 | `novel_semantic_search` 会加载本地 embedding 模型并建索引,首次调用耗时较长属正常。 |
|
|
194
|
+
| 正文乱码 | 章节文件需为 UTF-8;GBK 文件请先转码。 |
|
|
195
|
+
| 客户端看不到工具 | 确认 `args` 中 `server.mjs` 为绝对路径、`node` 在 PATH;查看客户端 MCP 日志中的 stderr 输出(应能看到「已注册 16 个工具」)。 |
|
|
196
|
+
| Windows 路径报错 | JSON 中反斜杠需转义 `\\`,或直接使用正斜杠 `/`。 |
|
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-novel-writer — stdio MCP 服务器
|
|
4
|
+
*
|
|
5
|
+
* 让 Claude Desktop / Cursor / 任何 MCP 客户端直接使用插件的 16 个 novel_* 工具。
|
|
6
|
+
* 实现方式:不复制任何业务逻辑——用 stub ctx 启动插件(lib/index.js 的 apply()),
|
|
7
|
+
* 捕获它注册的 { name, description, parameters, execute, output } 工具定义,
|
|
8
|
+
* 再把 MCP 的 tools/list / tools/call 映射到这些定义上。插件升级后本文件无需改动。
|
|
9
|
+
*
|
|
10
|
+
* 协议:换行分隔的 JSON-RPC 2.0(stdio),stdout 只走协议帧,日志一律写 stderr。
|
|
11
|
+
* 依赖:仅 Node 内置模块(不依赖 @modelcontextprotocol/sdk)。
|
|
12
|
+
*
|
|
13
|
+
* 用法:node mcp/server.mjs [--root <书库根目录>]
|
|
14
|
+
* --root 缺省时读环境变量 DSH_NOVEL_WRITER_ROOT,再缺省用 process.cwd()。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
|
|
22
|
+
const PLUGIN_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
24
|
+
const SERVER_NAME = "dsh-novel-writer";
|
|
25
|
+
const SERVER_VERSION = readPackageVersion();
|
|
26
|
+
const EXPECTED_TOOL_COUNT = 16;
|
|
27
|
+
|
|
28
|
+
/** 读插件 package.json 的 version(服务器版本随插件版本走)。 */
|
|
29
|
+
function readPackageVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(PLUGIN_DIR, "package.json"), "utf8"));
|
|
32
|
+
return typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "0.0.0";
|
|
33
|
+
} catch {
|
|
34
|
+
return "0.0.0";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 任意值 → 可读文本(日志/兜底输出用)。 */
|
|
39
|
+
function toText(value) {
|
|
40
|
+
if (typeof value === "string") return value;
|
|
41
|
+
if (value === undefined) return "undefined";
|
|
42
|
+
if (value === null) return "null";
|
|
43
|
+
if (value instanceof Error) return value.stack || value.message;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 日志只写 stderr——stdout 是 JSON-RPC 协议通道,绝不能被污染。 */
|
|
52
|
+
function log(message) {
|
|
53
|
+
try {
|
|
54
|
+
process.stderr.write(`[${SERVER_NAME}-mcp] ${message}\n`);
|
|
55
|
+
} catch {
|
|
56
|
+
/* stderr 不可写时静默(例如父进程已关闭管道) */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function logError(prefix, error) {
|
|
61
|
+
log(prefix + toText(error));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// stdout 保护:插件内部若出现 console.log/info/debug(当前 lib/ 无此类调用),
|
|
66
|
+
// 一律改道 stderr,避免任何一行非 JSON 内容混进协议通道。
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
for (const method of ["log", "info", "debug"]) {
|
|
69
|
+
console[method] = (...args) => {
|
|
70
|
+
try {
|
|
71
|
+
process.stderr.write(`[plugin:${method}] ${args.map(toText).join(" ")}\n`);
|
|
72
|
+
} catch {
|
|
73
|
+
/* 忽略 */
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// 书库根目录:命令行 --root > 环境变量 DSH_NOVEL_WRITER_ROOT > process.cwd()
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
function parseCliRoot(argv) {
|
|
82
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
83
|
+
const arg = argv[i];
|
|
84
|
+
if (arg === "--root") {
|
|
85
|
+
const value = argv[i + 1];
|
|
86
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
87
|
+
log("警告:--root 缺少路径值,忽略该参数");
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return value.trim();
|
|
91
|
+
}
|
|
92
|
+
if (arg.startsWith("--root=")) {
|
|
93
|
+
const value = arg.slice("--root=".length).trim();
|
|
94
|
+
if (value === "") {
|
|
95
|
+
log("警告:--root= 缺少路径值,忽略该参数");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveLibraryRoot(argv) {
|
|
105
|
+
const cliRoot = parseCliRoot(argv);
|
|
106
|
+
if (cliRoot !== null) return { root: resolve(cliRoot), source: "命令行 --root" };
|
|
107
|
+
const envRoot = process.env.DSH_NOVEL_WRITER_ROOT;
|
|
108
|
+
if (typeof envRoot === "string" && envRoot.trim() !== "") {
|
|
109
|
+
return { root: resolve(envRoot.trim()), source: "环境变量 DSH_NOVEL_WRITER_ROOT" };
|
|
110
|
+
}
|
|
111
|
+
return { root: process.cwd(), source: "process.cwd()" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { root: LIBRARY_ROOT, source: ROOT_SOURCE } = resolveLibraryRoot(process.argv.slice(2));
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 用 stub ctx 启动插件,捕获 16 个工具定义
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
const registry = [];
|
|
120
|
+
const ctx = {
|
|
121
|
+
tools: {
|
|
122
|
+
register(definition) {
|
|
123
|
+
if (definition && typeof definition.name === "string") registry.push(definition);
|
|
124
|
+
return () => {};
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
systemPrompt: { section: () => () => {} },
|
|
128
|
+
// 插件可选注入 UI 状态路由:headless 下用桩接管(effect 立即执行并返回 noop 清理器)
|
|
129
|
+
inject(_names, callback) {
|
|
130
|
+
try {
|
|
131
|
+
callback({
|
|
132
|
+
webServer: { register: () => () => {} },
|
|
133
|
+
effect: (fn) => (typeof fn === "function" ? fn() : undefined)
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
log("UI 路由注入跳过:" + toText(error && error.message));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const plugin = await import(pathToFileURL(join(PLUGIN_DIR, "lib", "index.js")).href);
|
|
143
|
+
await plugin.apply(ctx, { root: LIBRARY_ROOT });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logError("插件加载失败,服务器退出:", error);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const toolMap = new Map(registry.map((definition) => [definition.name, definition]));
|
|
150
|
+
log(`已注册 ${toolMap.size} 个工具;书库根目录 = ${LIBRARY_ROOT}(来源:${ROOT_SOURCE})`);
|
|
151
|
+
if (toolMap.size !== EXPECTED_TOOL_COUNT) {
|
|
152
|
+
log(`警告:工具数 ${toolMap.size} != 预期 ${EXPECTED_TOOL_COUNT}(插件升级后请同步检查)`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// JSON-RPC 2.0 响应构造
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
function rpcResult(id, result) {
|
|
159
|
+
return { jsonrpc: "2.0", id, result };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function rpcError(id, code, message, data) {
|
|
163
|
+
const error = { code, message };
|
|
164
|
+
if (data !== undefined) error.data = data;
|
|
165
|
+
return { jsonrpc: "2.0", id, error };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isNotification(message) {
|
|
169
|
+
return !Object.prototype.hasOwnProperty.call(message, "id") || message.id === undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// 工具调用:参数补 root、执行、渲染文本、错误隔离
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
function buildArgs(raw) {
|
|
176
|
+
const args = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {};
|
|
177
|
+
// 调用方未显式给 root 时注入服务器启动时确定的书库根目录
|
|
178
|
+
if (typeof args.root !== "string" || args.root.trim() === "") args.root = LIBRARY_ROOT;
|
|
179
|
+
return args;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 输出文本:优先采用插件 output.render 的字符串结果;插件侧 render 按 DSH 宿主契约
|
|
184
|
+
* 返回 [{type:"text",text}],MCP 只接受纯文本,故非字符串一律回退 JSON.stringify(value, null, 2)。
|
|
185
|
+
*/
|
|
186
|
+
function renderToolText(definition, args, value) {
|
|
187
|
+
const render = definition && definition.output ? definition.output.render : undefined;
|
|
188
|
+
if (typeof render === "function") {
|
|
189
|
+
try {
|
|
190
|
+
const rendered = render(args, value);
|
|
191
|
+
if (typeof rendered === "string") return rendered;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
log("render 失败,回退 JSON:" + toText(error && error.message));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (value === undefined) return "null";
|
|
197
|
+
try {
|
|
198
|
+
const text = JSON.stringify(value, null, 2);
|
|
199
|
+
return typeof text === "string" ? text : "null";
|
|
200
|
+
} catch (error) {
|
|
201
|
+
log("JSON 序列化失败,回退 String:" + toText(error && error.message));
|
|
202
|
+
return String(value);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toolListPayload() {
|
|
207
|
+
return {
|
|
208
|
+
tools: registry.map((definition) => ({
|
|
209
|
+
name: definition.name,
|
|
210
|
+
description: definition.description,
|
|
211
|
+
inputSchema: definition.parameters
|
|
212
|
+
}))
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function handleToolCall(id, params) {
|
|
217
|
+
const name = params && typeof params.name === "string" ? params.name : "";
|
|
218
|
+
if (name === "") {
|
|
219
|
+
return rpcError(id, -32602, "无效参数:tools/call 需要字符串 name");
|
|
220
|
+
}
|
|
221
|
+
const definition = toolMap.get(name);
|
|
222
|
+
if (!definition) {
|
|
223
|
+
// 未知工具也走 isError 内容(而不是协议级错误):客户端只会看到一条可读消息,不会中断会话
|
|
224
|
+
log(`未知工具:${name}`);
|
|
225
|
+
return rpcResult(id, {
|
|
226
|
+
content: [{
|
|
227
|
+
type: "text",
|
|
228
|
+
text: `错误:未知工具 ${name}(本服务器提供 ${toolMap.size} 个 novel_* 工具,可用 tools/list 查看)`
|
|
229
|
+
}],
|
|
230
|
+
isError: true
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const args = buildArgs(params.arguments);
|
|
234
|
+
const exec = { agent: { session: { header: { cwd: LIBRARY_ROOT } } } };
|
|
235
|
+
try {
|
|
236
|
+
const value = await definition.execute(args, exec);
|
|
237
|
+
return rpcResult(id, { content: [{ type: "text", text: renderToolText(definition, args, value) }] });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const message = error && error.message ? error.message : toText(error);
|
|
240
|
+
logError(`工具 ${name} 执行失败:`, error);
|
|
241
|
+
return rpcResult(id, {
|
|
242
|
+
content: [{ type: "text", text: "错误:" + message }],
|
|
243
|
+
isError: true
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// 方法分派
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
async function handleMessage(message) {
|
|
252
|
+
if (Array.isArray(message)) {
|
|
253
|
+
if (message.length === 0) return rpcError(null, -32600, "无效请求:空批次");
|
|
254
|
+
const responses = [];
|
|
255
|
+
for (const item of message) {
|
|
256
|
+
const response = await handleMessage(item);
|
|
257
|
+
if (response !== null) responses.push(response);
|
|
258
|
+
}
|
|
259
|
+
return responses.length > 0 ? responses : null;
|
|
260
|
+
}
|
|
261
|
+
if (message === null || typeof message !== "object") {
|
|
262
|
+
return rpcError(null, -32600, "无效请求:应为 JSON-RPC 对象");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { id, method, params } = message;
|
|
266
|
+
const notification = isNotification(message);
|
|
267
|
+
const respond = (payload) => (notification ? null : payload);
|
|
268
|
+
|
|
269
|
+
if (typeof method !== "string") {
|
|
270
|
+
return respond(rpcError(id === undefined ? null : id, -32600, "无效请求:缺少 method"));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
switch (method) {
|
|
274
|
+
case "initialize":
|
|
275
|
+
return respond(rpcResult(id, {
|
|
276
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
277
|
+
capabilities: { tools: {} },
|
|
278
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
|
|
279
|
+
}));
|
|
280
|
+
case "ping":
|
|
281
|
+
return respond(rpcResult(id, {}));
|
|
282
|
+
case "tools/list":
|
|
283
|
+
return respond(rpcResult(id, toolListPayload()));
|
|
284
|
+
case "tools/call":
|
|
285
|
+
if (notification) return null;
|
|
286
|
+
return handleToolCall(id, params);
|
|
287
|
+
// 通知类方法:无响应
|
|
288
|
+
case "notifications/initialized":
|
|
289
|
+
case "initialized":
|
|
290
|
+
case "notifications/cancelled":
|
|
291
|
+
case "notifications/roots/list_changed":
|
|
292
|
+
case "notifications/progress":
|
|
293
|
+
return null;
|
|
294
|
+
default:
|
|
295
|
+
log(`未知方法:${method}`);
|
|
296
|
+
return respond(rpcError(id, -32601, `Method not found: ${method}`));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function writeMessage(payload) {
|
|
301
|
+
try {
|
|
302
|
+
process.stdout.write(JSON.stringify(payload) + "\n");
|
|
303
|
+
} catch (error) {
|
|
304
|
+
log("stdout 写入失败(客户端可能已断开):" + toText(error && error.message));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function handleLine(line) {
|
|
309
|
+
const trimmed = typeof line === "string" ? line.trim() : "";
|
|
310
|
+
if (trimmed === "") return;
|
|
311
|
+
let message;
|
|
312
|
+
try {
|
|
313
|
+
message = JSON.parse(trimmed);
|
|
314
|
+
} catch {
|
|
315
|
+
// 非法 JSON 行:忽略并继续(不能退出——否则一行脏数据就杀死整个会话)
|
|
316
|
+
log("忽略非法 JSON 行:" + trimmed.slice(0, 200));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
let response;
|
|
320
|
+
try {
|
|
321
|
+
response = await handleMessage(message);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logError("处理请求失败:", error);
|
|
324
|
+
const id = message && typeof message === "object" && !Array.isArray(message) && message.id !== undefined
|
|
325
|
+
? message.id
|
|
326
|
+
: null;
|
|
327
|
+
response = rpcError(id, -32603, "内部错误:" + toText(error && error.message));
|
|
328
|
+
}
|
|
329
|
+
if (response !== null && response !== undefined) writeMessage(response);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// stdio 主循环(换行分隔 JSON-RPC 2.0)
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
const input = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
336
|
+
let queue = Promise.resolve();
|
|
337
|
+
let closed = false;
|
|
338
|
+
|
|
339
|
+
input.on("line", (line) => {
|
|
340
|
+
// 串行处理,保证同一连接内响应顺序与请求顺序一致
|
|
341
|
+
queue = queue.then(() => handleLine(line)).catch((error) => logError("行处理异常:", error));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
input.on("close", () => {
|
|
345
|
+
if (closed) return;
|
|
346
|
+
closed = true;
|
|
347
|
+
const done = () => process.exit(0);
|
|
348
|
+
// 等队列清空并让 stdout 落盘后再退出,避免最后一条响应被截断
|
|
349
|
+
queue
|
|
350
|
+
.then(() => new Promise((res) => {
|
|
351
|
+
try {
|
|
352
|
+
process.stdout.write("", res);
|
|
353
|
+
} catch {
|
|
354
|
+
res();
|
|
355
|
+
}
|
|
356
|
+
}))
|
|
357
|
+
.then(() => {
|
|
358
|
+
log("stdin 已关闭,服务器正常退出");
|
|
359
|
+
done();
|
|
360
|
+
})
|
|
361
|
+
.catch(() => done());
|
|
362
|
+
// 兜底:插件若残留句柄导致无法自然退出,5 秒后强制退出
|
|
363
|
+
const timer = setTimeout(() => {
|
|
364
|
+
log("退出兜底超时(5s),强制退出");
|
|
365
|
+
done();
|
|
366
|
+
}, 5000);
|
|
367
|
+
timer.unref();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
input.on("error", (error) => {
|
|
371
|
+
logError("stdin 读取失败:", error);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// 未捕获异常不让进程静默崩溃:全部记到 stderr
|
|
375
|
+
process.on("unhandledRejection", (reason) => {
|
|
376
|
+
logError("未处理的 Promise 拒绝:", reason);
|
|
377
|
+
});
|
|
378
|
+
process.on("uncaughtException", (error) => {
|
|
379
|
+
logError("未捕获异常:", error);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-novel-writer",
|
|
3
|
-
"version": "4.0
|
|
4
|
-
"description": "小说写作助手插件(v4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
|
+
"description": "小说写作助手插件(v4.1.0):句式/情感/意象分析、伏笔设定管理、本地语义检索、氛围光谱、风格画像报告、文笔六维基线带、原创模式与创作资料、写作哨兵(衔接/OOC/大纲);附 stdio MCP 服务器(Claude Desktop / Cursor 可用)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"README.md",
|
|
17
17
|
"README.en.md",
|
|
18
|
-
"skills"
|
|
18
|
+
"skills",
|
|
19
|
+
"mcp"
|
|
19
20
|
],
|
|
20
21
|
"dsh": {
|
|
21
22
|
"engines": {
|
|
@@ -72,5 +73,8 @@
|
|
|
72
73
|
"homepage": "https://github.com/siweina/dsh-novel-writer#readme",
|
|
73
74
|
"bugs": {
|
|
74
75
|
"url": "https://github.com/siweina/dsh-novel-writer/issues"
|
|
76
|
+
},
|
|
77
|
+
"bin": {
|
|
78
|
+
"dsh-novel-writer-mcp": "./mcp/server.mjs"
|
|
75
79
|
}
|
|
76
80
|
}
|