@4399ywkf/editor-mcp
v0.3.1
Published
把 @4399ywkf/editor 的结构化编辑面接出去:工具定义(transport 无关)+ 浏览器桥 + hub + stdio MCP server
Readme
@4399ywkf/editor-mcp
把 @4399ywkf/editor 的结构化编辑面接出去,让 AI 操作浏览器里那个真实的
编辑器实例(文档是 tiptap,表格是 Univer)—— AI 改的和用户看的是同一个。
分层
MCP 只是最外面那一层的一种传输。中间两层跟它没关系:
| 层 | 在哪 | 知道 MCP 吗 |
|---|---|---|
| runtime:LiteXML 编解码 + 8 类操作 | @4399ywkf/editor/doc-runtime | 不知道 |
| runtime:表格快照读 + facade 写 | @4399ywkf/editor/sheet-runtime | 不知道 |
| 工具面:name / JSON Schema / description | 本包 ./tools | 不知道 |
| 传输:浏览器桥 / hub / stdio MCP | 本包 ./bridge 与 bin/ | 知道 |
runtime 住在编辑器包里而不是这儿,是因为 NODE_TAG 镜像的是那个编辑器自己的
schema。跨包之后每加一个节点类型,序列化就静默降级成 <node type="..."/> 占位符 ——
不报错、不丢数据、也不告诉你。表格同理:对 Univer facade 的探测全钉在
sheet-runtime/facade.ts,Univer 版本一漂只改那一个文件。
两个 runtime 的 call(tool, args) 是同构的,桥不需要知道自己面对的是文档还是表格。
用法一:不走 MCP(推荐给已有 agent loop 的项目)
jizhi_ai 那种 builtin-tool-* executor 直接调的形态,只要工具定义和 runtime,
一行传输代码都不需要:
import { DOC_TOOLS } from "@4399ywkf/editor-mcp/tools"
import { DocRuntime } from "@4399ywkf/editor/doc-runtime"
const runtime = new DocRuntime()
// <NotionEditor onEditorReady={(editor) => runtime.setEditor(editor)} />
// 把 DOC_TOOLS 喂给模型,回调里:
const result = await runtime.call(toolName, args)DocRuntime 的方法名对齐了 jizhi_ai 的 @jizhi/editor-runtime
(getPageContent / modifyNodes / replaceText / setEditor / isReady),
两套编辑器可以被同一套工具面驱动。
表格换成 SHEET_TOOLS + SheetRuntime,形状一模一样:
import { SHEET_TOOLS } from "@4399ywkf/editor-mcp/tools"
import { SheetRuntime } from "@4399ywkf/editor/sheet-runtime"
const runtime = new SheetRuntime()
// <DocumentView format="sheet" onReady={(univerAPI) => runtime.setUniverAPI(univerAPI)} />两个都挂就喂 ALL_TOOLS(= DOC_TOOLS + SHEET_TOOLS),
执行侧用 composeRuntimes 按前缀分发(见下)。
用法二:走 MCP
agent ──stdio MCP──▶ ywkf-editor-mcp ──POST /api/call──▶ ywkf-editor-hub ──WS──▶ 浏览器
(只转发) (中继) (执行体)1. 页面侧接上桥
import { DocRuntime } from "@4399ywkf/editor/doc-runtime"
import { attachBridge } from "@4399ywkf/editor-mcp/bridge"
const runtime = useMemo(() => new DocRuntime(), [])
useEffect(
() => attachBridge({
runtime,
url: "ws://127.0.0.1:4399/bridge",
onStatus: (s) => setConn(s), // connecting | primary | observer | disconnected
}),
[runtime],
)
<NotionEditor onEditorReady={(editor) => runtime.setEditor(editor)} />样式记得引一次 @4399ywkf/editor/styles(AI 改动块的闪烁提示 .ai-flash 在里面)。
同一个页面上既有文档又有表格时,用 composeRuntimes 把两个运行时并成一个挂上去 ——
按工具名前缀路由,doc_* 去 tiptap,sheet_* 去 Univer:
import { attachBridge, composeRuntimes } from "@4399ywkf/editor-mcp/bridge"
const doc = useMemo(() => new DocRuntime(), [])
const sheet = useMemo(() => new SheetRuntime(), [])
useEffect(
() => attachBridge({ runtime: composeRuntimes({ doc, sheet }), url: "ws://127.0.0.1:4399/bridge" }),
[doc, sheet],
)只挂一个也行 —— 打到没挂的前缀时报的是「本页面没有挂 sheet_* 的运行时,可用的工具面: doc_*」,而不是「未知工具」。差别在于前者不会让模型换个工具名反复重试。
2. 起 hub
npx ywkf-editor-hub --port 4399 --token $(openssl rand -hex 16)默认只听 127.0.0.1。--token 可选但建议开:这个口的权限是「以用户身份重写他正在
编辑的文档」,不开的话本机任何进程都能调。--static <dir> 可以顺便托管一个页面,
本地调试方便。--out-dir <dir> 指定文档的读写目录(默认 hub 的工作目录),
doc_export_docx / doc_import_docx / sheet_export_xlsx 只能碰这个目录里的文件。
3. 注册 MCP
{
"mcpServers": {
"ywkf-editor": {
"command": "npx",
"args": ["ywkf-editor-mcp", "--url", "http://127.0.0.1:4399"],
"env": { "EDITOR_MCP_TOKEN": "刚才那个 token" }
}
}
}codex
exec下需要--dangerously-bypass-approvals-and-sandbox:approval: never会把 MCP 调用直接判成 "user cancelled"。交互式codex里可以逐次批准。
工具(doc 10 个 + sheet 6 个)
文档(tiptap)
| 工具 | 用途 |
|---|---|
| doc_read | 读全文,xml / json / both |
| doc_schema | 运行时反射出的节点·标记·标签映射·哪些类型带 id。永不与实现漂移 |
| doc_find | 检索,返回可直接回填的 nodeId |
| doc_get_selection | 用户此刻的选区;无选区时显式声明「不要沿用历史」 |
| doc_replace_text | 块内文本替换(首选),只改命中的那一段,其余格式保住 |
| doc_format_text | 加/去 bold·italic·underline·strike·code·sup·sub(改格式首选) |
| doc_modify_nodes | 结构化 insert / remove / modify |
| doc_table_edit | 表格:增删行列、合并拆分、批量写值 |
| doc_export_docx | 导出成 .docx 落盘,返回路径 + 统计 + 警告 |
| doc_import_docx | 读一份 .docx 灌进编辑器(整篇替换) |
刻意保持小。参照系:BlockNote AI 只有 3 个工具;把 tiptap 每个 command 都包成工具的
tiptap-apcore 做到 79 个,GitHub 1 star。后两个之所以够格占位置:没有它们,
模型能把文档改好却交付不出去 —— 「改完了,然后呢」是个死胡同。
工具描述里的可用标签清单是从 runtime 的标签表生成的,不是手抄的 —— 编辑器加一个
节点类型,description 自动跟着变(src/tools.test.ts 守这条)。
表格(Univer)
| 工具 | 用途 |
|---|---|
| sheet_read | 工作簿概览 + 一张表的 CSV 视图与公式清单。写之前必须先调它 |
| sheet_find | 全簿检索(值和公式都搜),返回可直接回填的 sheet + A1 ref |
| sheet_set_values | 从某格起写一块二维值,"=…" 是公式、null 清空 |
| sheet_edit_rows_cols | 插入 / 删除整行整列(0-based) |
| sheet_manage_sheets | 工作表 add / rename / delete / activate |
| sheet_export_xlsx | 导出成 .xlsx 落盘,返回路径 + 统计 + 警告 |
定位一律用 A1 引用,不用 node id —— 表格的坐标系本来就是稳定的,不需要 doc 那套 「读了才有 id」的仪式。但先读后写这条纪律不变,而且比 doc 更要紧:A1 引用不读也能 编出来,模型会照着想象中的表结构盲写。所以写工具的 description 里都写死了「先 sheet_read」。
读走快照、写走 facade 是有意分的两条路:读是纯数据(Univer 版本漂移伤不到), 且 CSV 文本的 token 成本只有 JSON 单元格矩阵的几分之一;写必须进 Univer 的 command 体系才有撤销栈和协同广播,直接改快照是死路。
sheet_manage_sheets 的 delete 不允许省略 sheet —— 「默认删活动表」是脚枪。
文档字节不进模型上下文
doc_export_docx / doc_import_docx / sheet_export_xlsx 的参数和返回值里没有文件内容,
只有路径:
模型 ──path──▶ hub ──base64──▶ 浏览器(真正的 serializeDocx / parseDocx / serializeXlsx)
模型 ◀──path── hub ◀──base64── 浏览器一份带图的 docx / xlsx 动辄几 MB,base64 之后更大,塞进上下文会直接把窗口撑爆。所以字节 只在 hub ↔ 浏览器之间走一趟,hub 负责落盘 / 读盘。这是 hub 唯一一处不"纯中继"的地方。
文件钉在 --out-dir(默认 hub 的工作目录)里,../ 和绝对路径一律拒 —— 那几个
路径参数是模型给的,「能读写用户整块磁盘」和「能改用户正在编辑的文档」是两个
量级的权限。
前提:编辑器实例得注册了 ExportDocx / ImportDocx 扩展(从
@4399ywkf/editor/docx 引入,通过 <NotionEditor extensions={[...]}> 注入)。
没注册时工具会报错并说明怎么装,而不是静默失败。表格侧没有这道手续 ——
sheet_export_xlsx 直接读 Univer 快照拼 OOXML,但它返回的 warnings 是
导出时丢了什么(图片、条件格式等插件数据),非空就得如实转述,别当成功报。
表格导入不在工具面里:.xlsx / .csv / .tsv 走 <DocumentView source={file}>
(实现在 @4399ywkf/editor/xlsx 的 importSheetSource)—— 那是用户打开文件的动作,
不是 AI 的动作。
两条硬规则
LiteXML 对 ProseMirror JSON 是有损压缩(省 1.55x 以上 token),所以:
modify以原节点 attrs 为底做合并,只让 XML 里出现过的键覆盖。否则textAlign/backgroundColor/language会被静默抹掉 —— 「AI 改一个词顺手毁排版」。- 未知节点降级成
<node type="..."/>,不抛错、不丢失。
原则:读可以有损压缩,写绝不要求模型重述它没看见的状态。
所以 doc_replace_text / doc_format_text(外科手术式)优先于
doc_modify_nodes(整块替换)。
已知限制
都源自同一件事:文档活在浏览器内存里。
- 刷新页面文档就回到初始内容。 真架构里文档应该活在浏览器之外(yjs / 服务端持有)。
- 多标签页 = 多份互不相干的文档。 现在是「第一个连上的持有桥,后开的降级旁观」,
onStatus会回observer,请在界面上显示出来。接了 yjs 之后自然消失。 - 没有 AI 编辑的审计与回滚。 ProseMirror 的 Step 可 invert,应该持久化 inverted
steps 做
doc_revert_turn,目前没做 —— 想兜底的话用attachBridge的onCall钩子自己存快照。表格侧写操作都走 Univer 的 command 体系,用户按 Ctrl+Z 撤得掉, 但同样没有「这一轮 AI 改了哪些格子」的审计记录。
开发
pnpm --filter @4399ywkf/editor build # 测试要读它的构建产物(标签表 / SheetRuntime)
pnpm --filter @4399ywkf/editor-mcp test # hub 端到端 + 工具面形状 + 两个 runtime 的不漂移