loomnote-mcp 0.1.0__py3-none-any.whl
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/README.md +71 -0
- loomnote_mcp/__init__.py +7 -0
- loomnote_mcp/_notes.py +28 -0
- loomnote_mcp/cli.py +133 -0
- loomnote_mcp/receipts_store.py +93 -0
- loomnote_mcp/sources/__init__.py +1 -0
- loomnote_mcp/sources/base.py +60 -0
- loomnote_mcp/sources/cos.py +134 -0
- loomnote_mcp/sources/disk.py +58 -0
- loomnote_mcp/tools.py +345 -0
- loomnote_mcp/tools_publish_task.py +146 -0
- loomnote_mcp-0.1.0.dist-info/METADATA +96 -0
- loomnote_mcp-0.1.0.dist-info/RECORD +33 -0
- loomnote_mcp-0.1.0.dist-info/WHEEL +5 -0
- loomnote_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- loomnote_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- loomnote_mcp-0.1.0.dist-info/top_level.txt +4 -0
- notes_core.py +183 -0
- publish/__init__.py +8 -0
- publish/channels/__init__.py +23 -0
- publish/channels/base.py +59 -0
- publish/channels/bilibili.py +41 -0
- publish/channels/reddit.py +41 -0
- publish/channels/wechat.py +167 -0
- publish/channels/weibo.py +43 -0
- publish/channels/xiaohongshu.py +80 -0
- publish/channels/youtube.py +42 -0
- publish/config.py +38 -0
- publish/model.py +63 -0
- publish/notes.py +58 -0
- publish/rules.py +200 -0
- publish/service.py +82 -0
- publish_receipts.py +270 -0
loomnote_mcp/README.md
ADDED
|
@@ -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
|
+
命令,直接复制粘贴,不用手拼。
|
loomnote_mcp/__init__.py
ADDED
|
@@ -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
|
+
"""
|
loomnote_mcp/_notes.py
ADDED
|
@@ -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)
|
loomnote_mcp/cli.py
ADDED
|
@@ -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
|
+
...
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""COS/S3 NoteSource: reads memos/ and documents directly from an S3-API
|
|
2
|
+
object store (Tencent COS, or plain AWS S3 / any S3-compatible endpoint).
|
|
3
|
+
|
|
4
|
+
Everything is read into memory and returned; nothing is cached to local disk.
|
|
5
|
+
Credentials are never handled here — the caller (cli.py) builds the boto3
|
|
6
|
+
client from environment variables and hands it in, so this module has no
|
|
7
|
+
notion of "secret" at all.
|
|
8
|
+
|
|
9
|
+
Key layout matches the web app's S3Adapter (web/src/adapter/s3/*.ts):
|
|
10
|
+
<prefix>memos/<name>.md, <prefix>publish-receipts.json, <prefix>templates/...
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import notes_core
|
|
18
|
+
|
|
19
|
+
from .base import check_writable
|
|
20
|
+
|
|
21
|
+
_MEMOS_PREFIX = "memos/"
|
|
22
|
+
_NOT_FOUND_CODES = {"NoSuchKey", "404", "NotFound"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_prefix(prefix: str) -> str:
|
|
26
|
+
prefix = (prefix or "").strip().strip("/")
|
|
27
|
+
return f"{prefix}/" if prefix else ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cos_endpoint(region: str) -> str:
|
|
31
|
+
"""Same formula as web/src/adapter/s3/paths.ts:cosEndpoint (no override)."""
|
|
32
|
+
return f"https://cos.{region}.myqcloud.com"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_client(
|
|
36
|
+
*,
|
|
37
|
+
region: Optional[str],
|
|
38
|
+
endpoint: Optional[str],
|
|
39
|
+
access_key_id: str,
|
|
40
|
+
secret_access_key: str,
|
|
41
|
+
):
|
|
42
|
+
"""Build the boto3 S3 client. Split out from CosSource so tests can stub
|
|
43
|
+
the client directly without importing/network-hitting boto3."""
|
|
44
|
+
import boto3
|
|
45
|
+
|
|
46
|
+
endpoint_url = endpoint or (cos_endpoint(region) if region else None)
|
|
47
|
+
if not endpoint_url:
|
|
48
|
+
raise ValueError("需要 --region(COS)或 --endpoint(S3 兼容存储)")
|
|
49
|
+
return boto3.client(
|
|
50
|
+
"s3",
|
|
51
|
+
endpoint_url=endpoint_url,
|
|
52
|
+
region_name=region or "us-east-1",
|
|
53
|
+
aws_access_key_id=access_key_id,
|
|
54
|
+
aws_secret_access_key=secret_access_key,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CosSource:
|
|
59
|
+
"""NoteSource backed by an S3-compatible bucket. `client` is any object
|
|
60
|
+
exposing boto3's S3 client surface (list_objects_v2/get_object/put_object).
|
|
61
|
+
|
|
62
|
+
Deliberately no delete_object call anywhere in this class, and no
|
|
63
|
+
delete_document on NoteSource at all: loomnote-mcp only ever needs to
|
|
64
|
+
append/update entries in publish-receipts.json (see write_document /
|
|
65
|
+
check_writable), never to remove an object from the bucket, so there is
|
|
66
|
+
no code path — and no tool — that can delete a note, an image, or
|
|
67
|
+
anything else the user stored.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, client: Any, bucket: str, prefix: str = ""):
|
|
71
|
+
self._client = client
|
|
72
|
+
self._bucket = bucket
|
|
73
|
+
self._prefix = normalize_prefix(prefix)
|
|
74
|
+
|
|
75
|
+
def load_notes(self) -> list[dict]:
|
|
76
|
+
notes = []
|
|
77
|
+
list_prefix = self._key(_MEMOS_PREFIX)
|
|
78
|
+
for key in self._list_keys(list_prefix):
|
|
79
|
+
if not key.endswith(".md"):
|
|
80
|
+
continue
|
|
81
|
+
text = self._get_text(key)
|
|
82
|
+
if text is None:
|
|
83
|
+
continue
|
|
84
|
+
note_id = key[len(self._prefix):]
|
|
85
|
+
tags, created, body = notes_core.parse(text)
|
|
86
|
+
notes.append({"id": note_id, "created": created, "tags": tags, "body": body})
|
|
87
|
+
notes.sort(key=lambda m: m["created"], reverse=True)
|
|
88
|
+
return notes
|
|
89
|
+
|
|
90
|
+
def read_document(self, path: str) -> str | None:
|
|
91
|
+
return self._get_text(self._key(path))
|
|
92
|
+
|
|
93
|
+
def write_document(self, path: str, text: str) -> None:
|
|
94
|
+
check_writable(path)
|
|
95
|
+
self._client.put_object(
|
|
96
|
+
Bucket=self._bucket,
|
|
97
|
+
Key=self._key(path),
|
|
98
|
+
Body=text.encode("utf-8"),
|
|
99
|
+
ContentType="application/octet-stream",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _key(self, path: str) -> str:
|
|
103
|
+
return f"{self._prefix}{path}"
|
|
104
|
+
|
|
105
|
+
def _list_keys(self, prefix: str) -> list[str]:
|
|
106
|
+
keys: list[str] = []
|
|
107
|
+
token: str | None = None
|
|
108
|
+
while True:
|
|
109
|
+
kwargs: dict[str, Any] = {"Bucket": self._bucket, "Prefix": prefix}
|
|
110
|
+
if token:
|
|
111
|
+
kwargs["ContinuationToken"] = token
|
|
112
|
+
resp = self._client.list_objects_v2(**kwargs)
|
|
113
|
+
keys.extend(obj["Key"] for obj in resp.get("Contents", []))
|
|
114
|
+
if not resp.get("IsTruncated"):
|
|
115
|
+
break
|
|
116
|
+
token = resp.get("NextContinuationToken")
|
|
117
|
+
return keys
|
|
118
|
+
|
|
119
|
+
def _get_text(self, key: str) -> str | None:
|
|
120
|
+
try:
|
|
121
|
+
resp = self._client.get_object(Bucket=self._bucket, Key=key)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
if _is_not_found(exc):
|
|
124
|
+
return None
|
|
125
|
+
raise
|
|
126
|
+
body = resp["Body"]
|
|
127
|
+
data = body.read() if hasattr(body, "read") else body
|
|
128
|
+
return data.decode("utf-8") if isinstance(data, bytes) else str(data)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_not_found(exc: Exception) -> bool:
|
|
132
|
+
response = getattr(exc, "response", None)
|
|
133
|
+
code = response.get("Error", {}).get("Code") if isinstance(response, dict) else None
|
|
134
|
+
return (code or type(exc).__name__) in _NOT_FOUND_CODES
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Disk NoteSource: today's mcp_server.py behavior, generalized to arbitrary
|
|
2
|
+
root-relative documents (not just memos/*.md).
|
|
3
|
+
|
|
4
|
+
`root` is the parent of the memos directory — the same directory that holds
|
|
5
|
+
publish-receipts.json and .config.json in the disk layout, so `read_document` /
|
|
6
|
+
`write_document` on e.g. "publish-receipts.json" or "memos/x.md" resolve the
|
|
7
|
+
same way `notes_core.read_one` already does for notes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import notes_core
|
|
15
|
+
|
|
16
|
+
from .base import check_writable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiskSource:
|
|
20
|
+
def __init__(self, notes_dir: str):
|
|
21
|
+
self.notes_dir = os.path.abspath(os.path.expanduser(notes_dir))
|
|
22
|
+
self.root = os.path.dirname(self.notes_dir)
|
|
23
|
+
|
|
24
|
+
def load_notes(self) -> list[dict]:
|
|
25
|
+
return notes_core.load(self.notes_dir)
|
|
26
|
+
|
|
27
|
+
def read_document(self, path: str) -> str | None:
|
|
28
|
+
fp = self._resolve(path)
|
|
29
|
+
if fp is None or not os.path.isfile(fp):
|
|
30
|
+
return None
|
|
31
|
+
with open(fp, encoding="utf-8") as f:
|
|
32
|
+
return f.read()
|
|
33
|
+
|
|
34
|
+
def write_document(self, path: str, text: str) -> None:
|
|
35
|
+
check_writable(path)
|
|
36
|
+
fp = self._resolve(path)
|
|
37
|
+
if fp is None:
|
|
38
|
+
raise ValueError(f"invalid document path: {path!r}")
|
|
39
|
+
parent = os.path.dirname(fp)
|
|
40
|
+
if parent:
|
|
41
|
+
os.makedirs(parent, exist_ok=True)
|
|
42
|
+
tmp = fp + ".tmp"
|
|
43
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
44
|
+
f.write(text)
|
|
45
|
+
os.replace(tmp, fp)
|
|
46
|
+
|
|
47
|
+
def _resolve(self, path: str) -> str | None:
|
|
48
|
+
"""Root-relative path -> absolute path, or None if it escapes root
|
|
49
|
+
(leading '/', '..' segment, or a symlink that resolves outside root)."""
|
|
50
|
+
if not path or path.startswith("/") or ".." in path.split("/"):
|
|
51
|
+
return None
|
|
52
|
+
fp = os.path.normpath(os.path.join(self.root, path))
|
|
53
|
+
if fp == self.root or not fp.startswith(self.root + os.sep):
|
|
54
|
+
return None
|
|
55
|
+
real_root = os.path.realpath(self.root)
|
|
56
|
+
if not (os.path.realpath(fp) + os.sep).startswith(real_root + os.sep):
|
|
57
|
+
return None
|
|
58
|
+
return fp
|