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 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
+ 命令,直接复制粘贴,不用手拼。
@@ -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