loomnote-mcp 0.1.0__tar.gz
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.
- loomnote_mcp-0.1.0/LICENSE +21 -0
- loomnote_mcp-0.1.0/MANIFEST.in +5 -0
- loomnote_mcp-0.1.0/PKG-INFO +96 -0
- loomnote_mcp-0.1.0/README.md +84 -0
- loomnote_mcp-0.1.0/loomnote_mcp/README.md +71 -0
- loomnote_mcp-0.1.0/loomnote_mcp/__init__.py +7 -0
- loomnote_mcp-0.1.0/loomnote_mcp/_notes.py +28 -0
- loomnote_mcp-0.1.0/loomnote_mcp/cli.py +133 -0
- loomnote_mcp-0.1.0/loomnote_mcp/receipts_store.py +93 -0
- loomnote_mcp-0.1.0/loomnote_mcp/sources/__init__.py +1 -0
- loomnote_mcp-0.1.0/loomnote_mcp/sources/base.py +60 -0
- loomnote_mcp-0.1.0/loomnote_mcp/sources/cos.py +134 -0
- loomnote_mcp-0.1.0/loomnote_mcp/sources/disk.py +58 -0
- loomnote_mcp-0.1.0/loomnote_mcp/tools.py +345 -0
- loomnote_mcp-0.1.0/loomnote_mcp/tools_publish_task.py +146 -0
- loomnote_mcp-0.1.0/loomnote_mcp.egg-info/PKG-INFO +96 -0
- loomnote_mcp-0.1.0/loomnote_mcp.egg-info/SOURCES.txt +37 -0
- loomnote_mcp-0.1.0/loomnote_mcp.egg-info/dependency_links.txt +1 -0
- loomnote_mcp-0.1.0/loomnote_mcp.egg-info/entry_points.txt +2 -0
- loomnote_mcp-0.1.0/loomnote_mcp.egg-info/requires.txt +2 -0
- loomnote_mcp-0.1.0/loomnote_mcp.egg-info/top_level.txt +4 -0
- loomnote_mcp-0.1.0/notes_core.py +183 -0
- loomnote_mcp-0.1.0/publish/__init__.py +8 -0
- loomnote_mcp-0.1.0/publish/channels/__init__.py +23 -0
- loomnote_mcp-0.1.0/publish/channels/base.py +59 -0
- loomnote_mcp-0.1.0/publish/channels/bilibili.py +41 -0
- loomnote_mcp-0.1.0/publish/channels/reddit.py +41 -0
- loomnote_mcp-0.1.0/publish/channels/wechat.py +167 -0
- loomnote_mcp-0.1.0/publish/channels/weibo.py +43 -0
- loomnote_mcp-0.1.0/publish/channels/xiaohongshu.py +80 -0
- loomnote_mcp-0.1.0/publish/channels/youtube.py +42 -0
- loomnote_mcp-0.1.0/publish/config.py +38 -0
- loomnote_mcp-0.1.0/publish/model.py +63 -0
- loomnote_mcp-0.1.0/publish/notes.py +58 -0
- loomnote_mcp-0.1.0/publish/rules.py +200 -0
- loomnote_mcp-0.1.0/publish/service.py +82 -0
- loomnote_mcp-0.1.0/publish_receipts.py +270 -0
- loomnote_mcp-0.1.0/pyproject.toml +43 -0
- loomnote_mcp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MyAgentHubs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# loomnote-mcp sdist file list. setuptools' sdist command unconditionally
|
|
2
|
+
# globs a root-level tests/ directory into the source distribution unless
|
|
3
|
+
# told otherwise here — prune it so the sdist matches the wheel (only the
|
|
4
|
+
# loomnote_mcp package plus its two runtime-imported sibling modules).
|
|
5
|
+
prune tests
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loomnote-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Loomnote MCP server: read notes and record multi-channel publish receipts from your own storage (disk, Tencent COS, or S3). / Loomnote 的 MCP 服务端:从你自己的存储(磁盘、腾讯云 COS 或 S3)读笔记、记发布回执。
|
|
5
|
+
Author: MyAgentHubs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://loomnote.myagenthubs.com
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Communications
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: mcp<2,>=1.29
|
|
23
|
+
Requires-Dist: boto3>=1.34
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# loomnote-mcp
|
|
27
|
+
|
|
28
|
+
Loomnote 笔记的 MCP 服务端:在你自己的电脑上启动,用你自己的存储凭证直连你的
|
|
29
|
+
笔记数据(本地磁盘 / 腾讯云 COS / S3 兼容存储),让 AI 客户端(Claude Code、
|
|
30
|
+
Codex 等)读笔记、把笔记分发到公众号/小红书/微博/B站/Reddit/YouTube,并把
|
|
31
|
+
发布结果写回一份回执文件。Loomnote 的服务器不在这条数据路径上。
|
|
32
|
+
|
|
33
|
+
## 安全模型
|
|
34
|
+
|
|
35
|
+
- **默认只读**。`record_publish_receipt` / `publish_note` /
|
|
36
|
+
`update_publish_receipt` 三个写工具始终注册(客户端能看到它们),但调用会
|
|
37
|
+
返回 `{"ok": false, "error": "..."}`,直到你加 `--allow-publish-writes`
|
|
38
|
+
重新启动。
|
|
39
|
+
- **只写一个文件**:即使开了可写模式,唯一可能被写入的文件是
|
|
40
|
+
`publish-receipts.json`(发布回执)。笔记正文(`memos/`)、图片/视频
|
|
41
|
+
(`file/`)、模板(`templates/`)、配置(`.config.json`)在写入前就会被
|
|
42
|
+
拒绝,抛 `PermissionError`。
|
|
43
|
+
- **没有删除功能**。这个 MCP 不提供、也不会去调用任何删除对象/文件的接口——
|
|
44
|
+
无论是否加 `--allow-publish-writes`。
|
|
45
|
+
- **凭证只走环境变量**,从不出现在命令行参数或返回给 AI 客户端的内容里;
|
|
46
|
+
建议给 COS/S3 单独开一个只有读 + 对 `publish-receipts.json` 有写权限的
|
|
47
|
+
子账号密钥,而不是主账号全量密钥。
|
|
48
|
+
|
|
49
|
+
## 安装
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uvx loomnote-mcp --help # 免安装直接跑
|
|
53
|
+
# 或
|
|
54
|
+
pipx install loomnote-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 启动
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 本地磁盘(memos/ 所在目录)
|
|
61
|
+
uvx loomnote-mcp --source disk --notes-dir /path/to/memos
|
|
62
|
+
|
|
63
|
+
# 腾讯云 COS
|
|
64
|
+
export LOOMNOTE_COS_SECRET_ID=...
|
|
65
|
+
export LOOMNOTE_COS_SECRET_KEY=...
|
|
66
|
+
uvx loomnote-mcp --source cos --bucket <bucket> --region <region> --prefix <可选前缀>
|
|
67
|
+
|
|
68
|
+
# 开启可写回执(默认关闭,见上面的安全模型)
|
|
69
|
+
uvx loomnote-mcp --source cos --bucket <bucket> --region <region> --allow-publish-writes
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
参数一览:`--source {disk,cos,s3}`、`--notes-dir`(disk)、`--bucket` /
|
|
73
|
+
`--region`(cos)/ `--endpoint`(s3 兼容)、`--prefix`、`--allow-publish-writes`、
|
|
74
|
+
`--transport stdio`(目前只支持 stdio)。
|
|
75
|
+
|
|
76
|
+
## 自检
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uvx loomnote-mcp --source disk --notes-dir /path/to/memos --selfcheck
|
|
80
|
+
# loomnote-mcp 已连通 · 数据源 disk(...) · N 条笔记 · 11 个工具 · 只读
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`N` 应等于 Loomnote App 里的笔记总数;对不上说明没连到正确的数据源。
|
|
84
|
+
|
|
85
|
+
## 客户端配置示例(Claude Code)
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
claude mcp add loomnote \
|
|
89
|
+
-e LOOMNOTE_COS_SECRET_ID=<你的子账号 SecretId> \
|
|
90
|
+
-e LOOMNOTE_COS_SECRET_KEY=<你的子账号 SecretKey> \
|
|
91
|
+
-- uvx loomnote-mcp --source cos --bucket <your-bucket> --region <your-region>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
其他支持 stdio MCP 的客户端(Codex 等)同理,把上面的命令粘进各自的 MCP
|
|
95
|
+
服务器配置里即可。Loomnote App 的「AI 接入」页会按你的实际存储配置生成这段
|
|
96
|
+
命令,直接复制粘贴,不用手拼。
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# loomnote
|
|
2
|
+
|
|
3
|
+
「自己拥有数据」的标签式速记 app —— 深色、标签树 + 时间倒序卡片流 + 常驻输入框。
|
|
4
|
+
本地用一个极小 Python 服务读写**纯 markdown**;Web 版可把用户自己的 GitHub 仓库、S3-compatible 桶或 Google Drive `LoomNote` 文件夹作为同步来源。具体云厂商的兼容性与部署状态以当前版本为准。
|
|
5
|
+
|
|
6
|
+
界面支持简体中文、繁体中文、English、日本語、한국어。
|
|
7
|
+
|
|
8
|
+
## 数据主权
|
|
9
|
+
|
|
10
|
+
**你的笔记,归你。**
|
|
11
|
+
- GitHub 同步把笔记存在**你自己的**仓库;S3-compatible 适配写入用户配置的桶;Google Drive 同步写入应用创建的 `LoomNote` 文件夹 —— 归你、无锁定、随时导出。
|
|
12
|
+
- 我们**没有数据库、不持久化存储**你的正文;本地优先:working copy 在你设备,durable copy 在你的仓库、桶或 Drive 文件夹。
|
|
13
|
+
|
|
14
|
+
诚实说明:① 我们发的前端 JS 需要你信任(发什么、你浏览器就执行什么,非零信任);② GitHub 笔记读写由浏览器直连 `api.github.com`;OAuth token exchange 会经过响应设为 `no-store` 的边缘 Worker,应用代码不持久化、不主动记录 token;③ S3-compatible 数据由浏览器直连用户配置的桶;④ Google Drive 正文与图片由浏览器直连 `www.googleapis.com`,只申请 `drive.file`(仅访问同一 OAuth 应用创建或打开的文件,不读取用户其余 Drive 文件);短期 access token 只存在当前页面内存,刷新页面或过期后需用户主动重连;⑤ 浏览器缓存是明文、落在你自己的设备。
|
|
15
|
+
|
|
16
|
+
## 启用 Google Drive(Web)
|
|
17
|
+
|
|
18
|
+
Google Drive 同步代码已保留,但官方 www 在完成验收前暂时关闭:生产构建未设置 `VITE_GOOGLE_DRIVE_ENABLED`,因此默认安全关闭,统一 Settings 不展示 Google Drive;即使浏览器里留有旧 Drive 配置,启动也会强制使用本地存储且不会删除该配置。当前 Google Drive 仅支持 Web,原生桌面/iOS 无论构建 flag 如何都不展示;原生授权仍是后续工作。
|
|
19
|
+
|
|
20
|
+
自建或测试 Web 版本必须同时显式开启 capability 并配置 OAuth client ID:
|
|
21
|
+
|
|
22
|
+
1. 在 Google Cloud 项目中启用 **Google Drive API**。
|
|
23
|
+
2. 创建 **Web application** 类型的 OAuth client,并把实际使用的本地 origin(例如 Vite 开发地址或 `http://localhost:5005`)和生产 origin 分别加入 **Authorized JavaScript origins**;origin 只填协议、主机与端口,不猜测或复用他人的生产域名。
|
|
24
|
+
3. 在本地或构建平台同时设置 `VITE_GOOGLE_DRIVE_ENABLED=true` 和 `VITE_GOOGLE_DRIVE_CLIENT_ID`,再构建 `web/`。只有 flag 精确为 `true` 才启用;缺失或其他值都保持关闭。本地可使用已被 git 忽略的 `web/.env.local`。
|
|
25
|
+
|
|
26
|
+
Google Identity Services 的 Web token 流程不需要 client secret:前端和仓库中都不应出现 client secret。**绝不提交 access token、refresh token、client secret 或包含它们的环境文件**;Google access token 也不会写入 `localStorage` / `sessionStorage`。
|
|
27
|
+
|
|
28
|
+
## 本地跑(开发 / 自用)
|
|
29
|
+
python3 server.py # 浏览器开 http://localhost:5005
|
|
30
|
+
|
|
31
|
+
笔记在 `memos/`(每条一个 .md),图片在 `file/`,icon 配置在 `.config.json` —— **均不入库**(见 .gitignore)。
|
|
32
|
+
|
|
33
|
+
### 启用微信公众号草稿
|
|
34
|
+
|
|
35
|
+
公众号发布是可选能力;先安装网络依赖,再重启本地服务:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python3 -m pip install -r requirements-wechat.txt
|
|
39
|
+
python3 server.py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
公众号配置仅是统一 Settings 中的一项。在其中填写 AppID、AppSecret,并按公众号 IP 白名单要求选择直连、HTTP(S) 或 SOCKS5(H) 代理。Note 的 `···` 保留「保存到公众号草稿箱」文章操作;连接未验证时会引导进入设置,通过「保存并测试连接」后才进入草稿编辑。正文内由 Loomnote 管理的 JPG/PNG 会自动上传,外部图片会被阻止并提示先上传。封面当前由用户选择,发送目标是公众号**草稿箱**,不会自动群发。
|
|
43
|
+
|
|
44
|
+
凭证默认只保存在本机的 `.wechat/`(已被 git 忽略);也可以显式选择同步到当前 GitHub/S3/Google Drive 数据源,但该模式会在用户自己的数据源中以可读文本保存敏感信息,界面会要求再次确认风险。未配置远程 capability 的静态 Web 不显示发布入口;iOS 使用原生 Keychain 与原生网络层。
|
|
45
|
+
|
|
46
|
+
### 生产 www 的公众号发布
|
|
47
|
+
|
|
48
|
+
生产 www 的托管发布代码链已经接入;是否已部署及是否通过真账号 smoke,以对应环境的实际配置和验收记录为准,不能仅凭仓库代码宣称上线。配置完成后,浏览器通过 Cloudflare 把请求转发到 LoomNote 管理的 OpenClaw gateway。固定出口 IP 是 `47.253.109.175`,使用前必须加入公众号后台的 IP 白名单。www 只允许直连固定出口,不接受用户自定义 proxy;HTTP(S)/SOCKS proxy 仅供本地版与 iOS/native 使用。如需协助设置本地或 iOS proxy,可联系 `support@myagenthubs.com`。
|
|
49
|
+
|
|
50
|
+
测试连接、生成预览和保存草稿时,按操作所需,AppSecret、文章正文、封面和正文图片会经 HTTPS 通过 Cloudflare 与 LoomNote OpenClaw gateway。AppSecret 默认只在浏览器页面内存与 gateway 进程内存中处理。网关会话建立后最多 15 分钟便不再接受新操作,空闲会话记录会主动移除;到期前已开始的操作中,AppSecret、文章和图片可能保留在内存中,直至该操作完成或网络超时。LoomNote 应用层不会把这些数据写入服务器磁盘、数据库或 HTTP 日志。浏览器默认也不持久化 AppSecret,刷新页面后必须重新填写。只有用户主动选择 `currentSource`(“同步到当前数据源”)时,AppSecret 才会以明文写入用户自己的当前数据源。
|
|
51
|
+
|
|
52
|
+
每个公众号账号使用各自独立的 AppID/AppSecret,但当前托管链共用同一个出口 IP。若业务必须隔离出口,需要另购公网 IP/VPS 并单独部署,不能在现有共享出口内逻辑分配新 IP。无论使用哪条链路,LoomNote 只调用草稿接口,只创建草稿,不会自动群发。
|
|
53
|
+
|
|
54
|
+
运维必须生成至少 32 个字符的高熵 origin token:Cloudflare 端保存为 secret `WECHAT_ORIGIN_TOKEN`,OpenClaw gateway 端的 `LOOMNOTE_WECHAT_ORIGIN_TOKEN` 必须使用同一值。token 不得写入仓库、文档示例、构建产物或日志;轮换时须协调更新双端并撤销旧值,避免一端先切换造成服务中断。
|
|
55
|
+
|
|
56
|
+
生产 www smoke 步骤:
|
|
57
|
+
|
|
58
|
+
1. 确认 Cloudflare 与 OpenClaw gateway 均已配置、HTTPS 有效,且 capability 返回固定出口 `47.253.109.175`。
|
|
59
|
+
2. 在公众号后台把 `47.253.109.175` 加入 IP 白名单,再从生产 www 的统一 Settings 打开公众号配置。远程设置中不应出现自定义 proxy 输入。
|
|
60
|
+
3. 填写该公众号独立的 AppID/AppSecret,阅读并确认远程处理说明,执行“保存并测试连接”。
|
|
61
|
+
4. 选一篇含正文图片的 Note,选择 JPG/PNG 封面,生成预览并保存;在公众号后台确认草稿箱出现该文章,同时确认没有发生群发。
|
|
62
|
+
5. 刷新页面,确认默认模式下 AppSecret 需要重新填写;若主动选择 `currentSource`,须先看到明文风险确认,并只写入用户自己的当前数据源。
|
|
63
|
+
6. 验证会话过期或重启 gateway 后旧 session/preview 不可继续使用,并检查应用日志、服务器磁盘与数据库均未出现 AppSecret。
|
|
64
|
+
|
|
65
|
+
## 文件
|
|
66
|
+
- `server.py` —— 本地服务:读写 markdown + 图片上传 + 改/删/还原(回收站)+ config。
|
|
67
|
+
- `app.html` —— 单文件前端:标签树(可换 icon / 折叠 / 引导线)、时间流、输入框(标签自动补全 / `#`高亮 / 工具栏 / 专注模式)、markdown(加粗/斜体/有序无序/待办复选框/行内图文)、搜索高亮、就地编辑、灯箱。
|
|
68
|
+
- `notes_core.py` —— 共享**只读**逻辑(`parse/load/read_one/search`),`server.py` 与 MCP 复用同一份。
|
|
69
|
+
- `mcp_server.py` —— 本地**只读** MCP server(FastMCP stdio),给你自己的 AI 客户端读笔记。
|
|
70
|
+
|
|
71
|
+
## 用你自己的 AI 读笔记(MCP)
|
|
72
|
+
|
|
73
|
+
一个本地**只读** MCP server,让你用**自己的** AI 客户端(Claude Desktop / Claude Code)读自己的笔记 —— 搜索 / 总结 / 问答 / 串联。**全程在你机器上,正文不经我们**。
|
|
74
|
+
|
|
75
|
+
1. 装依赖:`python3 -m pip install -r requirements.txt`(只 MCP server 需要;`server.py` 仍零三方依赖)。
|
|
76
|
+
2. 跑着 `server.py`,在 app 菜单点 **🤖 AI 连接** —— 面板自动给出配置片 + 命令(路径已填好)。
|
|
77
|
+
3. **Claude Desktop**:把配置片粘进 `~/Library/Application Support/Claude/claude_desktop_config.json`;**Claude Code**:终端跑面板给的 `claude mcp add loomnote --scope user -- …`(macOS)。
|
|
78
|
+
4. **重启** Claude 客户端。验证:`python3 mcp_server.py --selfcheck` 应打印 `OK, <N> notes`。
|
|
79
|
+
|
|
80
|
+
工具(**只读**):`search_notes` / `get_note` / `list_notes` / `list_tags`。写类(建/改/删)v1 不开放。
|
|
81
|
+
|
|
82
|
+
## 路线
|
|
83
|
+
1. **独立产品**:Web 版已实现 GitHub、S3-compatible 与 Google Drive 同步;Google Drive 还需要显式 feature flag 与 OAuth client ID,官方 www 当前默认关闭至验收完成。数据全归用户。
|
|
84
|
+
2. **集成 AgentLoom**:作为 `Local` 下一个笔记 workspace(见 `INTEGRATION-AGENTLOOM.md`)。
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# loomnote-mcp
|
|
2
|
+
|
|
3
|
+
Loomnote 笔记的 MCP 服务端:在你自己的电脑上启动,用你自己的存储凭证直连你的
|
|
4
|
+
笔记数据(本地磁盘 / 腾讯云 COS / S3 兼容存储),让 AI 客户端(Claude Code、
|
|
5
|
+
Codex 等)读笔记、把笔记分发到公众号/小红书/微博/B站/Reddit/YouTube,并把
|
|
6
|
+
发布结果写回一份回执文件。Loomnote 的服务器不在这条数据路径上。
|
|
7
|
+
|
|
8
|
+
## 安全模型
|
|
9
|
+
|
|
10
|
+
- **默认只读**。`record_publish_receipt` / `publish_note` /
|
|
11
|
+
`update_publish_receipt` 三个写工具始终注册(客户端能看到它们),但调用会
|
|
12
|
+
返回 `{"ok": false, "error": "..."}`,直到你加 `--allow-publish-writes`
|
|
13
|
+
重新启动。
|
|
14
|
+
- **只写一个文件**:即使开了可写模式,唯一可能被写入的文件是
|
|
15
|
+
`publish-receipts.json`(发布回执)。笔记正文(`memos/`)、图片/视频
|
|
16
|
+
(`file/`)、模板(`templates/`)、配置(`.config.json`)在写入前就会被
|
|
17
|
+
拒绝,抛 `PermissionError`。
|
|
18
|
+
- **没有删除功能**。这个 MCP 不提供、也不会去调用任何删除对象/文件的接口——
|
|
19
|
+
无论是否加 `--allow-publish-writes`。
|
|
20
|
+
- **凭证只走环境变量**,从不出现在命令行参数或返回给 AI 客户端的内容里;
|
|
21
|
+
建议给 COS/S3 单独开一个只有读 + 对 `publish-receipts.json` 有写权限的
|
|
22
|
+
子账号密钥,而不是主账号全量密钥。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx loomnote-mcp --help # 免安装直接跑
|
|
28
|
+
# 或
|
|
29
|
+
pipx install loomnote-mcp
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 启动
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 本地磁盘(memos/ 所在目录)
|
|
36
|
+
uvx loomnote-mcp --source disk --notes-dir /path/to/memos
|
|
37
|
+
|
|
38
|
+
# 腾讯云 COS
|
|
39
|
+
export LOOMNOTE_COS_SECRET_ID=...
|
|
40
|
+
export LOOMNOTE_COS_SECRET_KEY=...
|
|
41
|
+
uvx loomnote-mcp --source cos --bucket <bucket> --region <region> --prefix <可选前缀>
|
|
42
|
+
|
|
43
|
+
# 开启可写回执(默认关闭,见上面的安全模型)
|
|
44
|
+
uvx loomnote-mcp --source cos --bucket <bucket> --region <region> --allow-publish-writes
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
参数一览:`--source {disk,cos,s3}`、`--notes-dir`(disk)、`--bucket` /
|
|
48
|
+
`--region`(cos)/ `--endpoint`(s3 兼容)、`--prefix`、`--allow-publish-writes`、
|
|
49
|
+
`--transport stdio`(目前只支持 stdio)。
|
|
50
|
+
|
|
51
|
+
## 自检
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uvx loomnote-mcp --source disk --notes-dir /path/to/memos --selfcheck
|
|
55
|
+
# loomnote-mcp 已连通 · 数据源 disk(...) · N 条笔记 · 11 个工具 · 只读
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`N` 应等于 Loomnote App 里的笔记总数;对不上说明没连到正确的数据源。
|
|
59
|
+
|
|
60
|
+
## 客户端配置示例(Claude Code)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
claude mcp add loomnote \
|
|
64
|
+
-e LOOMNOTE_COS_SECRET_ID=<你的子账号 SecretId> \
|
|
65
|
+
-e LOOMNOTE_COS_SECRET_KEY=<你的子账号 SecretKey> \
|
|
66
|
+
-- uvx loomnote-mcp --source cos --bucket <your-bucket> --region <your-region>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
其他支持 stdio MCP 的客户端(Codex 等)同理,把上面的命令粘进各自的 MCP
|
|
70
|
+
服务器配置里即可。Loomnote App 的「AI 接入」页会按你的实际存储配置生成这段
|
|
71
|
+
命令,直接复制粘贴,不用手拼。
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""loomnote-mcp: a small, source-agnostic MCP server for Loomnote notes.
|
|
2
|
+
|
|
3
|
+
Reads notes and records multi-channel publish receipts from the user's own
|
|
4
|
+
storage (local disk, or their COS/S3 bucket via `--source cos|s3`), in memory
|
|
5
|
+
only — nothing is cached to disk when reading from an object store. See
|
|
6
|
+
`docs/publish/publish-space-design.md` §3 for the design this implements.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared note-lookup helpers used by both loomnote_mcp/tools.py and
|
|
2
|
+
loomnote_mcp/tools_publish_task.py. Split out so tools_publish_task.py doesn't
|
|
3
|
+
have to import from tools.py (that would be circular: tools.py imports
|
|
4
|
+
tools_publish_task.py to register its two tools).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import notes_core
|
|
10
|
+
|
|
11
|
+
from .sources.base import NoteSource
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_note(source: NoteSource, note_id: str) -> dict:
|
|
15
|
+
if not note_id or not note_id.endswith(".md") or note_id.startswith("/") or ".." in note_id.split("/"):
|
|
16
|
+
raise ValueError(f"invalid note id: {note_id!r}")
|
|
17
|
+
text = source.read_document(note_id)
|
|
18
|
+
if text is None:
|
|
19
|
+
raise ValueError(f"note not found: {note_id!r}")
|
|
20
|
+
tags, created, body = notes_core.parse(text)
|
|
21
|
+
return {"id": note_id, "created": created, "tags": tags, "body": body}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def try_read_note(source: NoteSource, note_id: str) -> tuple[dict | None, str]:
|
|
25
|
+
try:
|
|
26
|
+
return read_note(source, note_id), ""
|
|
27
|
+
except ValueError as e:
|
|
28
|
+
return None, str(e)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""loomnote-mcp command-line entry point.
|
|
2
|
+
|
|
3
|
+
Parses --source/credentials, builds the matching NoteSource, and starts the
|
|
4
|
+
MCP server over stdio. This is what `uvx loomnote-mcp --source cos ...` runs
|
|
5
|
+
(see docs/publish/publish-space-design.md §3.2 for the end-to-end story).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .sources.base import NoteSource
|
|
15
|
+
from .sources.cos import CosSource, build_client
|
|
16
|
+
from .sources.disk import DiskSource
|
|
17
|
+
|
|
18
|
+
_COS_ENV_ID = "LOOMNOTE_COS_SECRET_ID"
|
|
19
|
+
_COS_ENV_KEY = "LOOMNOTE_COS_SECRET_KEY"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_arg_parser(*, include_selfcheck: bool = False) -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="loomnote-mcp", description="Loomnote MCP server (stdio)")
|
|
24
|
+
parser.add_argument("--source", choices=["disk", "cos", "s3"], default="disk")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--notes-dir",
|
|
27
|
+
dest="notes_dir",
|
|
28
|
+
default=os.path.join(os.getcwd(), "memos"),
|
|
29
|
+
help="disk source only: the memos/ directory path",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--bucket", default=None, help="cos/s3 source: bucket name")
|
|
32
|
+
parser.add_argument("--region", default=None, help="cos source: region, e.g. ap-shanghai")
|
|
33
|
+
parser.add_argument("--prefix", default="", help="cos/s3 source: root key prefix (default: none)")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--endpoint",
|
|
36
|
+
default=None,
|
|
37
|
+
help="s3 source: endpoint URL (cos source derives this from --region instead)",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--transport", default="stdio", choices=["stdio"])
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--allow-publish-writes",
|
|
42
|
+
dest="allow_publish_writes",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help=(
|
|
45
|
+
"Allow record_publish_receipt/publish_note/update_publish_receipt to actually "
|
|
46
|
+
"write publish-receipts.json. Default: off (read-only) — those three tools "
|
|
47
|
+
"still register, but each call returns {ok: false, error} until this is set. "
|
|
48
|
+
"Loomnote's 'AI 接入' page shows the exact command once AI 代发 is turned on."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
if include_selfcheck:
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--selfcheck",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Verify the source is reachable and print a summary; exit 0 on success.",
|
|
56
|
+
)
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_source(args: argparse.Namespace) -> NoteSource:
|
|
61
|
+
if args.source == "disk":
|
|
62
|
+
return DiskSource(args.notes_dir)
|
|
63
|
+
return _build_object_store_source(args)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_object_store_source(args: argparse.Namespace) -> CosSource:
|
|
67
|
+
if not args.bucket:
|
|
68
|
+
_fail("缺少 --bucket 参数")
|
|
69
|
+
if args.source == "cos" and not args.region and not args.endpoint:
|
|
70
|
+
_fail("COS 数据源需要 --region(或 --endpoint)")
|
|
71
|
+
|
|
72
|
+
access_key_id = os.environ.get(_COS_ENV_ID)
|
|
73
|
+
secret_access_key = os.environ.get(_COS_ENV_KEY)
|
|
74
|
+
if not access_key_id or not secret_access_key:
|
|
75
|
+
_fail(f"缺少凭证:请设置环境变量 {_COS_ENV_ID} 和 {_COS_ENV_KEY}")
|
|
76
|
+
|
|
77
|
+
client = build_client(
|
|
78
|
+
region=args.region,
|
|
79
|
+
endpoint=args.endpoint,
|
|
80
|
+
access_key_id=access_key_id,
|
|
81
|
+
secret_access_key=secret_access_key,
|
|
82
|
+
)
|
|
83
|
+
return CosSource(client, args.bucket, args.prefix)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fail(message: str) -> None:
|
|
87
|
+
print(f"loomnote-mcp: {message}", file=sys.stderr)
|
|
88
|
+
sys.exit(2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _source_label(args: argparse.Namespace) -> str:
|
|
92
|
+
"""The "数据源 ..." segment of the --selfcheck summary line."""
|
|
93
|
+
if args.source == "disk":
|
|
94
|
+
return f"disk({args.notes_dir})"
|
|
95
|
+
kind = "COS" if args.source == "cos" else "S3"
|
|
96
|
+
return f"{kind}(bucket={args.bucket}, region={args.region})"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _selfcheck(args: argparse.Namespace, source: NoteSource, tool_names: list[str]) -> int:
|
|
100
|
+
"""Connect once, print a one-line Chinese summary, exit 0/1 (credential
|
|
101
|
+
errors already exited 2 earlier, in build_source)."""
|
|
102
|
+
try:
|
|
103
|
+
note_count = len(source.load_notes())
|
|
104
|
+
except Exception as exc: # any backend's connection/auth/parse failure
|
|
105
|
+
print(f"连接失败:{exc}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
write_mode = "可写回执" if getattr(args, "allow_publish_writes", False) else "只读"
|
|
108
|
+
print(
|
|
109
|
+
f"loomnote-mcp 已连通 · 数据源 {_source_label(args)} · {note_count} 条笔记 · "
|
|
110
|
+
f"{len(tool_names)} 个工具 · {write_mode}"
|
|
111
|
+
)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main(argv: list[str] | None = None) -> None:
|
|
116
|
+
args = build_arg_parser(include_selfcheck=True).parse_args(argv)
|
|
117
|
+
source = build_source(args)
|
|
118
|
+
|
|
119
|
+
from mcp.server.fastmcp import FastMCP
|
|
120
|
+
|
|
121
|
+
from .tools import register_tools
|
|
122
|
+
|
|
123
|
+
mcp = FastMCP("loomnote")
|
|
124
|
+
tool_names = register_tools(mcp, source, allow_publish_writes=args.allow_publish_writes)
|
|
125
|
+
|
|
126
|
+
if args.selfcheck:
|
|
127
|
+
sys.exit(_selfcheck(args, source, tool_names))
|
|
128
|
+
|
|
129
|
+
mcp.run(transport=args.transport)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""publish-receipts store, abstracted over a document read/write callback pair
|
|
2
|
+
instead of a filesystem path — same schema (v2), same validation/dedup/cap
|
|
3
|
+
rules as `publish_receipts.py`, reused from there rather than re-implemented.
|
|
4
|
+
|
|
5
|
+
`publish_receipts.py`'s own path-based load/append/list_for_note/update stay
|
|
6
|
+
untouched (their 14 existing tests keep passing unmodified); this module is
|
|
7
|
+
the COS/S3-friendly sibling used via NoteSource.read_document/write_document.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
import publish_receipts as pr
|
|
16
|
+
|
|
17
|
+
DocumentReader = Callable[[str], Optional[str]]
|
|
18
|
+
DocumentWriter = Callable[[str, str], None]
|
|
19
|
+
|
|
20
|
+
DEFAULT_DOC_PATH = pr.STORE_FILENAME
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(read_document: DocumentReader, doc_path: str = DEFAULT_DOC_PATH) -> list[dict]:
|
|
24
|
+
text = read_document(doc_path)
|
|
25
|
+
if text is None:
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(text)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return []
|
|
31
|
+
if not isinstance(data, dict) or not isinstance(data.get("receipts"), list):
|
|
32
|
+
return []
|
|
33
|
+
normalized = [pr.normalize_receipt(r) for r in data["receipts"] if isinstance(r, dict)]
|
|
34
|
+
return pr._dedupe_and_cap([r for r in normalized if r is not None])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def append(
|
|
38
|
+
read_document: DocumentReader,
|
|
39
|
+
write_document: DocumentWriter,
|
|
40
|
+
seed: dict,
|
|
41
|
+
doc_path: str = DEFAULT_DOC_PATH,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""Validate, dedup, persist. Raises ValueError on invalid seed."""
|
|
44
|
+
receipt = pr.normalize_receipt(seed)
|
|
45
|
+
if receipt is None:
|
|
46
|
+
raise ValueError("invalid receipt: channel/status 非法或字段超长")
|
|
47
|
+
receipts = load(read_document, doc_path)
|
|
48
|
+
receipts.append(receipt)
|
|
49
|
+
receipts = pr._dedupe_and_cap(receipts)
|
|
50
|
+
_write(write_document, doc_path, receipts)
|
|
51
|
+
return receipt
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_for_note(
|
|
55
|
+
read_document: DocumentReader,
|
|
56
|
+
note_id: str,
|
|
57
|
+
current_body: str | None = None,
|
|
58
|
+
doc_path: str = DEFAULT_DOC_PATH,
|
|
59
|
+
) -> list[dict]:
|
|
60
|
+
"""Receipts for one note, newest first. Pass current_body to add a derived `stale` flag."""
|
|
61
|
+
rows = [r for r in load(read_document, doc_path) if r["note_id"] == note_id]
|
|
62
|
+
if current_body is None:
|
|
63
|
+
return rows
|
|
64
|
+
return [{**r, "stale": pr.is_stale(r, current_body)} for r in rows]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def update(
|
|
68
|
+
read_document: DocumentReader,
|
|
69
|
+
write_document: DocumentWriter,
|
|
70
|
+
receipt_id: str,
|
|
71
|
+
patch: dict,
|
|
72
|
+
doc_path: str = DEFAULT_DOC_PATH,
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""Merge patch into an existing receipt. Raises KeyError if the id is
|
|
75
|
+
unknown, ValueError if the merged receipt is invalid."""
|
|
76
|
+
if not isinstance(patch, dict):
|
|
77
|
+
raise ValueError("patch must be an object")
|
|
78
|
+
receipts = load(read_document, doc_path)
|
|
79
|
+
for i, r in enumerate(receipts):
|
|
80
|
+
if r["receipt_id"] == receipt_id:
|
|
81
|
+
merged = {**r, **{k: v for k, v in patch.items() if k not in pr._IMMUTABLE}}
|
|
82
|
+
updated = pr.normalize_receipt(merged)
|
|
83
|
+
if updated is None:
|
|
84
|
+
raise ValueError("invalid patch: channel/status 非法或字段超长")
|
|
85
|
+
receipts[i] = updated
|
|
86
|
+
_write(write_document, doc_path, pr._dedupe_and_cap(receipts))
|
|
87
|
+
return updated
|
|
88
|
+
raise KeyError(receipt_id)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write(write_document: DocumentWriter, doc_path: str, receipts: list[dict]) -> None:
|
|
92
|
+
payload = {"version": pr.VERSION, "receipts": receipts}
|
|
93
|
+
write_document(doc_path, json.dumps(payload, ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""NoteSource implementations: disk (today's behavior) and COS/S3 (object store)."""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""NoteSource: the storage-agnostic interface every backend implements.
|
|
2
|
+
|
|
3
|
+
Three operations are enough to build all 11 MCP tools on top of: list every
|
|
4
|
+
note, read any root-relative document (memos/, but also publish-receipts.json
|
|
5
|
+
and templates/wechat/*.json — same key layout the web app's
|
|
6
|
+
S3Adapter/S3DocumentStore use, see web/src/adapter/s3/s3Documents.ts), and
|
|
7
|
+
write — but writing is deliberately not "arbitrary": loomnote-mcp is started
|
|
8
|
+
by the user's own AI client with the user's own storage credentials, so an
|
|
9
|
+
untrusted or careless LLM must not be able to touch note content. See
|
|
10
|
+
check_writable() below, which both DiskSource and CosSource call before ever
|
|
11
|
+
touching storage.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
# The ONLY document loomnote-mcp is ever allowed to write. Every write tool
|
|
19
|
+
# (record_publish_receipt / publish_note / update_publish_receipt) ultimately
|
|
20
|
+
# goes through receipts_store, which always targets this exact filename
|
|
21
|
+
# (publish_receipts.STORE_FILENAME) — so this whitelist costs nothing in
|
|
22
|
+
# functionality and closes off everything else: notes, images, templates,
|
|
23
|
+
# config. Enforced here at the NoteSource boundary rather than trusted to
|
|
24
|
+
# callers, so a bug in a tool (or a future tool nobody reviewed) can't widen
|
|
25
|
+
# what gets written.
|
|
26
|
+
WRITABLE_DOCUMENTS = frozenset({"publish-receipts.json"})
|
|
27
|
+
|
|
28
|
+
# Checked, and rejected, before the WRITABLE_DOCUMENTS membership check even
|
|
29
|
+
# runs — so these stay hard-denied even if WRITABLE_DOCUMENTS is ever widened
|
|
30
|
+
# by mistake.
|
|
31
|
+
_FORBIDDEN_WRITE_PREFIXES = ("memos/", "file/", "templates/")
|
|
32
|
+
_FORBIDDEN_WRITE_EXACT = frozenset({".config.json"})
|
|
33
|
+
|
|
34
|
+
_PERMISSION_MESSAGE = "loomnote-mcp 只允许写回执文件(publish-receipts.json)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_writable(path: str) -> None:
|
|
38
|
+
"""Raise PermissionError unless `path` is the one document loomnote-mcp
|
|
39
|
+
may write. Call this first thing in every NoteSource.write_document."""
|
|
40
|
+
if path in _FORBIDDEN_WRITE_EXACT or path.startswith(_FORBIDDEN_WRITE_PREFIXES):
|
|
41
|
+
raise PermissionError(_PERMISSION_MESSAGE)
|
|
42
|
+
if path not in WRITABLE_DOCUMENTS:
|
|
43
|
+
raise PermissionError(_PERMISSION_MESSAGE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class NoteSource(Protocol):
|
|
48
|
+
def load_notes(self) -> list[dict]:
|
|
49
|
+
"""All notes as {id, created, tags, body}, newest first by `created`."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def read_document(self, path: str) -> Optional[str]:
|
|
53
|
+
"""Read one root-relative document (e.g. 'publish-receipts.json',
|
|
54
|
+
'memos/2026-09-02-1000.md'). Returns None if it doesn't exist."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def write_document(self, path: str, text: str) -> None:
|
|
58
|
+
"""Write (create or replace) publish-receipts.json — the only path
|
|
59
|
+
check_writable() allows. Raises PermissionError for anything else."""
|
|
60
|
+
...
|