mmm-mcp-server 0.1.3 → 0.2.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 CHANGED
@@ -11,7 +11,7 @@ A tool that lets you read and write your own maps in Matryoshka Mind Map (a mind
11
11
 
12
12
  This tool runs entirely on your computer, with no always-on server. It starts only when needed and accesses your own maps with your own login credentials (the developer cannot see your data).
13
13
 
14
- > **For users who installed via `git clone` before September 30, 2026**: Due to a data format migration in the app, older versions will fail when writing to maps. Please replace the registration in Step 5 below with `npx -y mmm-mcp-server` (you do not need to log in again). If you want to keep using your cloned copy, run `git pull && npm install && npm run build` in the `mmm_mcp` folder and restart your AI agent.
14
+ > **For users who installed via `git clone` before September 30, 2026**: Due to a data format migration in the app, older versions will fail when writing to maps. Please replace the registration in Step 5 below with `npx -y mmm-mcp-server@latest` (you do not need to log in again). If you want to keep using your cloned copy, run `git pull && npm install && npm run build` in the `mmm_mcp` folder and restart your AI agent.
15
15
 
16
16
  > This guide walks you through every step, starting with how to open the terminal (the window where you type commands), so that you can follow along even if you are not used to working with a computer.
17
17
  >
@@ -66,7 +66,7 @@ Choose one of the following three options based on how you usually log in to the
66
66
  ### If you log in with Google
67
67
 
68
68
  ```bash
69
- npx -y -p mmm-mcp-server mmm-login login --method google
69
+ npx -y -p mmm-mcp-server@latest mmm-login login --method google
70
70
  ```
71
71
 
72
72
  Your browser opens automatically; log in with the Google account you usually use.
@@ -74,7 +74,7 @@ Your browser opens automatically; log in with the Google account you usually use
74
74
  ### If you log in with Apple
75
75
 
76
76
  ```bash
77
- npx -y -p mmm-mcp-server mmm-login login --method apple
77
+ npx -y -p mmm-mcp-server@latest mmm-login login --method apple
78
78
  ```
79
79
 
80
80
  Your browser opens automatically; log in with your Apple ID.
@@ -82,7 +82,7 @@ Your browser opens automatically; log in with your Apple ID.
82
82
  ### If you log in with an email address and password
83
83
 
84
84
  ```bash
85
- npx -y -p mmm-mcp-server mmm-login login --method email
85
+ npx -y -p mmm-mcp-server@latest mmm-login login --method email
86
86
  ```
87
87
 
88
88
  You will be asked for your email address and password (the password is not shown on screen).
@@ -92,23 +92,23 @@ You will be asked for your email address and password (the password is not shown
92
92
  Once you have logged in, you do not need to log in again each time (your credentials are stored only on your computer, in a form that only you can read).
93
93
 
94
94
  ```bash
95
- npx -y -p mmm-mcp-server mmm-login status
95
+ npx -y -p mmm-mcp-server@latest mmm-login status
96
96
  ```
97
97
 
98
98
  shows your login status. To log out:
99
99
 
100
100
  ```bash
101
- npx -y -p mmm-mcp-server mmm-login logout
101
+ npx -y -p mmm-mcp-server@latest mmm-login logout
102
102
  ```
103
103
 
104
104
  ## 5. Register It with Your AI Agent
105
105
 
106
- Below are the instructions for Claude Desktop and Claude Code, which have been tested. If you use another MCP-compatible client (e.g., Codex), in most cases you only need to register the server launch command (`npx -y mmm-mcp-server`) in that client's MCP server settings. See your client's documentation for the exact steps.
106
+ Below are the instructions for Claude Desktop and Claude Code, which have been tested. If you use another MCP-compatible client (e.g., Codex), in most cases you only need to register the server launch command (`npx -y mmm-mcp-server@latest`) in that client's MCP server settings. See your client's documentation for the exact steps.
107
107
 
108
108
  ### Claude Code
109
109
 
110
110
  ```bash
111
- claude mcp add mmm -- npx -y mmm-mcp-server
111
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
112
112
  ```
113
113
 
114
114
  ### Claude Desktop
@@ -120,7 +120,7 @@ Open the Claude Desktop configuration file (`claude_desktop_config.json`) and ad
120
120
  "mcpServers": {
121
121
  "mmm": {
122
122
  "command": "npx",
123
- "args": ["-y", "mmm-mcp-server"]
123
+ "args": ["-y", "mmm-mcp-server@latest"]
124
124
  }
125
125
  }
126
126
  }
@@ -164,6 +164,38 @@ The reasons:
164
164
  - **Do not mix URLs with text**: A URL in the same element as other text is not treated as a link in the app, so tapping it does nothing.
165
165
  - **Chronological order**: Compared with ordering by task name, checking items off in order makes it easier to notice anything you missed.
166
166
 
167
+ ## Updating to the Latest Version
168
+
169
+ If your registration in Step 5 uses `mmm-mcp-server@latest`, the latest version is used every time your AI agent starts, so there is nothing to do.
170
+
171
+ If you registered with `npx -y mmm-mcp-server` (without `@latest`), you may still be running the old version that was downloaded the first time. Update the registration as follows (you do not need to log in again).
172
+
173
+ ### Claude Code
174
+
175
+ Remove the registration, then register it again.
176
+
177
+ ```bash
178
+ claude mcp remove mmm
179
+ ```
180
+
181
+ ```bash
182
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
183
+ ```
184
+
185
+ If you registered with `--scope user`, add `--scope user` to both commands.
186
+
187
+ ```bash
188
+ claude mcp remove mmm --scope user
189
+ ```
190
+
191
+ ```bash
192
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
193
+ ```
194
+
195
+ ### Claude Desktop
196
+
197
+ Change `"args"` in `claude_desktop_config.json` to `["-y", "mmm-mcp-server@latest"]`, save it, and restart Claude Desktop.
198
+
167
199
  ## Troubleshooting
168
200
 
169
201
  | Symptom | Cause / Solution |
@@ -189,7 +221,7 @@ What follows is **just one example of how it can be used**. It is not a standard
189
221
  When registering with Claude Code in Step 5, the `mmm` tool is only available in the current folder by default. To use it no matter which folder you start `claude` in, register it with `--scope user`:
190
222
 
191
223
  ```bash
192
- claude mcp add mmm --scope user -- npx -y mmm-mcp-server
224
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
193
225
  ```
194
226
 
195
227
  **(2) Write the workflow rules in your global CLAUDE.md (`~/.claude/CLAUDE.md`)**
package/README.md CHANGED
@@ -20,7 +20,7 @@ Desktop・Claude Codeに限らず、MCPに対応したAIエージェントであ
20
20
 
21
21
  > **2026年9月30日より前に `git clone` で導入した方へ**: アプリ側のデータ形式の
22
22
  > 切り替えに伴い、古い版ではマップへの書き込みが失敗するようになります。下の手順5の
23
- > 登録を `npx -y mmm-mcp-server` に置き換えてください(ログインし直す必要はありません)。
23
+ > 登録を `npx -y mmm-mcp-server@latest` に置き換えてください(ログインし直す必要はありません)。
24
24
  > clone したまま使い続ける場合は、`mmm_mcp` フォルダで `git pull && npm install && npm run build`
25
25
  > を実行してからAIエージェントを再起動してください。
26
26
 
@@ -98,7 +98,7 @@ Matryoshka Mind Mapアプリで普段どのログイン方法を使っている
98
98
  ### Googleでログインしている場合
99
99
 
100
100
  ```bash
101
- npx -y -p mmm-mcp-server mmm-login login --method google
101
+ npx -y -p mmm-mcp-server@latest mmm-login login --method google
102
102
  ```
103
103
 
104
104
  ブラウザが自動で開くので、いつも使っているGoogleアカウントでログインします。
@@ -106,7 +106,7 @@ npx -y -p mmm-mcp-server mmm-login login --method google
106
106
  ### Appleでログインしている場合
107
107
 
108
108
  ```bash
109
- npx -y -p mmm-mcp-server mmm-login login --method apple
109
+ npx -y -p mmm-mcp-server@latest mmm-login login --method apple
110
110
  ```
111
111
 
112
112
  こちらもブラウザが自動で開くので、Apple IDでログインします。
@@ -114,7 +114,7 @@ npx -y -p mmm-mcp-server mmm-login login --method apple
114
114
  ### メールアドレスとパスワードでログインしている場合
115
115
 
116
116
  ```bash
117
- npx -y -p mmm-mcp-server mmm-login login --method email
117
+ npx -y -p mmm-mcp-server@latest mmm-login login --method email
118
118
  ```
119
119
 
120
120
  メールアドレスとパスワードを聞かれるので入力してください(パスワードは
@@ -131,27 +131,27 @@ npx -y -p mmm-mcp-server mmm-login login --method email
131
131
  あなたのパソコンの中だけに、あなた以外読めない形で保存されます)。
132
132
 
133
133
  ```bash
134
- npx -y -p mmm-mcp-server mmm-login status
134
+ npx -y -p mmm-mcp-server@latest mmm-login status
135
135
  ```
136
136
 
137
137
  でログイン状態を確認できます。ログアウトしたい場合は次のコマンドです。
138
138
 
139
139
  ```bash
140
- npx -y -p mmm-mcp-server mmm-login logout
140
+ npx -y -p mmm-mcp-server@latest mmm-login logout
141
141
  ```
142
142
 
143
143
  ## 5. AIエージェントにMatryoshkaを教える
144
144
 
145
145
  ここでは動作確認済みのClaude Desktop / Claude Codeへの登録方法を示します。
146
146
  他のMCP対応クライアント(例: Codexなど)を使っている場合も、多くは同じように
147
- 「サーバーを起動するコマンド」(`npx -y mmm-mcp-server`)をそのクライアントの
147
+ 「サーバーを起動するコマンド」(`npx -y mmm-mcp-server@latest`)をそのクライアントの
148
148
  MCPサーバー設定に登録するだけで使えるはずです。具体的な登録方法はお使いの
149
149
  クライアントのドキュメントを確認してください。
150
150
 
151
151
  ### Claude Codeを使っている場合
152
152
 
153
153
  ```bash
154
- claude mcp add mmm -- npx -y mmm-mcp-server
154
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
155
155
  ```
156
156
 
157
157
  ### Claude Desktopを使っている場合
@@ -164,7 +164,7 @@ Claude Desktopの設定ファイル(`claude_desktop_config.json`)を開き
164
164
  "mcpServers": {
165
165
  "mmm": {
166
166
  "command": "npx",
167
- "args": ["-y", "mmm-mcp-server"]
167
+ "args": ["-y", "mmm-mcp-server@latest"]
168
168
  }
169
169
  }
170
170
  }
@@ -223,6 +223,42 @@ AIエージェントに任せると楽な反面、そのままだと読みにく
223
223
  - **時系列で並べる** … 作業の名前で並べるより、順に消していく形になるぶん、
224
224
  抜けに気づきやすくなります
225
225
 
226
+ ## 最新版にする
227
+
228
+ 手順5の登録が `mmm-mcp-server@latest` になっていれば、AIエージェントを起動するたびに
229
+ 最新版が使われるので、何もする必要はありません。
230
+
231
+ `@latest` の付いていない `npx -y mmm-mcp-server` で登録した方は、最初に取得した古い版を
232
+ 使い続けていることがあります。次のように登録を書き換えてください(ログインし直す必要は
233
+ ありません)。
234
+
235
+ ### Claude Codeを使っている場合
236
+
237
+ 登録を一度消してから、登録し直します。
238
+
239
+ ```bash
240
+ claude mcp remove mmm
241
+ ```
242
+
243
+ ```bash
244
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
245
+ ```
246
+
247
+ `--scope user` で登録していた方は、消すときも登録するときも `--scope user` を付けます。
248
+
249
+ ```bash
250
+ claude mcp remove mmm --scope user
251
+ ```
252
+
253
+ ```bash
254
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
255
+ ```
256
+
257
+ ### Claude Desktopを使っている場合
258
+
259
+ `claude_desktop_config.json` の `"args"` を `["-y", "mmm-mcp-server@latest"]` に書き換えて保存し、
260
+ Claude Desktopを再起動してください。
261
+
226
262
  ## よくあるトラブル
227
263
 
228
264
  | 症状 | 原因・対処 |
@@ -257,7 +293,7 @@ AIエージェントに任せると楽な反面、そのままだと読みにく
257
293
  するには、`--scope user`を付けて登録します。
258
294
 
259
295
  ```bash
260
- claude mcp add mmm --scope user -- npx -y mmm-mcp-server
296
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
261
297
  ```
262
298
 
263
299
  **(2) グローバルのCLAUDE.md(`~/.claude/CLAUDE.md`)に運用ルールを書く**
@@ -45,7 +45,7 @@ export class AuthSession {
45
45
  }
46
46
  export class NotSignedInError extends Error {
47
47
  constructor() {
48
- super("サインインしていません。ターミナルで `npx -y -p mmm-mcp-server mmm-login login --method google` を実行してください(email / apple も選べます。git clone して使っている場合は、そのフォルダで `npm run login -- login --method google`)。");
48
+ super("サインインしていません。ターミナルで `npx -y -p mmm-mcp-server@latest mmm-login login --method google` を実行してください(email / apple も選べます。git clone して使っている場合は、そのフォルダで `npm run login -- login --method google`)。");
49
49
  this.name = "NotSignedInError";
50
50
  }
51
51
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 1要素の本文の上限(UTF-16の単位、JavaScriptのlength)。子の一覧は1ドキュメントに
3
+ * 入るため、巨大な書き込み1回でドキュメントの上限1MiBを超えないようにする。
4
+ * アプリの lib/domain/detail_limit.dart と同じ値にする。決めごとは docs/paid-plan.md §7。
5
+ */
6
+ export const MAX_DETAIL_LENGTH = 5000;
7
+ /** 上限を超えたときにClaudeへ返す説明。黙って切らず、分けて書き直してもらう。 */
8
+ export function tooLongDetailMessage(length) {
9
+ return (`要素の本文が${length}文字あり、1要素の上限(${MAX_DETAIL_LENGTH}文字)を超えています。` +
10
+ `何も書き込んでいません。複数の要素に分けて書き直してください。`);
11
+ }
12
+ /** [details]のうち上限を超えるものがあれば、何も書かずに済むよう例外を投げる。 */
13
+ export function assertDetailLengths(details) {
14
+ for (const detail of details) {
15
+ if (detail.length > MAX_DETAIL_LENGTH)
16
+ throw new Error(tooLongDetailMessage(detail.length));
17
+ }
18
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 新しいマップを一覧の先頭に置くとき、既存の自分のマップの`order`を
3
+ * どう書き換えるかを計算する(Firestoreに触れない)。
4
+ * lib/header_list/header_order.dart の prependedHeaderOrders と同じ考え方。
5
+ *
6
+ * 新しいマップを1にし、既存はアプリの一覧と同じ並び(orderの昇順、
7
+ * 同値はドキュメントidの順)のまま2..n+1に振り直す。値が変わるものだけを返す。
8
+ * 欠番と重複もこの1回で解消する。
9
+ */
10
+ export function prependedHeaderOrders(entries) {
11
+ const sorted = [...entries].sort((a, b) => a.order !== b.order ? a.order - b.order : a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
12
+ const updates = [];
13
+ sorted.forEach((entry, position) => {
14
+ if (entry.order !== position + 2)
15
+ updates.push({ id: entry.id, order: position + 2 });
16
+ });
17
+ return updates;
18
+ }
19
+ /**
20
+ * create_map の書き込み一式。既存の自分のマップを2..n+1に振り直し、新しいマップを
21
+ * order 1 で作る。order を持たないマップは一覧に出ないので触らない。
22
+ * 振り直しは読んだ時点の updateTime を前提条件にする(読んだ後に別の端末で消された
23
+ * マップを、order だけのドキュメントとして作り直さないため)。
24
+ */
25
+ export function buildCreateMapWrites(headersPath, existing, headerId, title, isTodo) {
26
+ const updateTimes = new Map(existing.map((doc) => [doc.id, doc.updateTime]));
27
+ const shifts = prependedHeaderOrders(existing
28
+ .filter((doc) => typeof doc.data.order === "number")
29
+ .map((doc) => ({ id: doc.id, order: doc.data.order })));
30
+ const headerPath = `${headersPath}/${headerId}`;
31
+ return [
32
+ ...shifts.map((s) => ({
33
+ path: `${headersPath}/${s.id}`,
34
+ fields: { order: s.order },
35
+ requireUpdateTime: updateTimes.get(s.id),
36
+ })),
37
+ { path: headerPath, fields: { title, order: 1, isTodo, editors: [], viewers: [] } },
38
+ { path: `${headerPath}/levels/root`, fields: { children: [] } },
39
+ ];
40
+ }
@@ -3,6 +3,10 @@
3
3
  //
4
4
  // levels/{levelId}
5
5
  // children: [ { id, detail, done }, ... ]
6
+ // schedule: { 子のid: { due?: {at, allDay}, start?: {at, allDay} }, ... }
7
+ // (日付を持つ子だけ。docs/schedule.md §2)
8
+ // links: { 子のid: [ { uid, headerId }, ... ], ... }
9
+ // (マップ間リンクを持つ子だけ。docs/map-links.md §8)
6
10
  //
7
11
  // levelIdはマップ最上位が'root'、それ以外はその要素自身のid。配列の並び順が
8
12
  // そのまま兄弟順。子を持つ要素だけドキュメントが存在する(無ければ葉)。
@@ -32,12 +36,58 @@ export function childrenOf(data) {
32
36
  }
33
37
  return result;
34
38
  }
35
- /** levels配下の全ドキュメントを読み、levelId -> children[] のMapにする。 */
36
- export async function readAllLevelDocs(client, levelsPath) {
37
- const docs = await client.listDocuments(levelsPath);
39
+ function toScheduleDate(raw) {
40
+ if (typeof raw !== "object" || raw === null)
41
+ return undefined;
42
+ const obj = raw;
43
+ if (!(obj.at instanceof Date) || typeof obj.allDay !== "boolean")
44
+ return undefined;
45
+ return { at: obj.at, allDay: obj.allDay };
46
+ }
47
+ /** levelsドキュメントの schedule を子のid -> 日付にする。形の崩れたもの・空のものは読み飛ばす。 */
48
+ export function scheduleOf(data) {
49
+ const result = new Map();
50
+ const raw = data.schedule;
51
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
52
+ return result;
53
+ for (const [id, value] of Object.entries(raw)) {
54
+ if (typeof value !== "object" || value === null)
55
+ continue;
56
+ const obj = value;
57
+ const entry = {};
58
+ const due = toScheduleDate(obj.due);
59
+ const start = toScheduleDate(obj.start);
60
+ if (due)
61
+ entry.due = due;
62
+ if (start)
63
+ entry.start = start;
64
+ if (due || start)
65
+ result.set(id, entry);
66
+ }
67
+ return result;
68
+ }
69
+ /** levelsドキュメントの links を子のid -> リンク先にする。形の崩れたもの・空のもの・重複は読み飛ばす。 */
70
+ export function linksOf(data) {
38
71
  const result = new Map();
39
- for (const doc of docs) {
40
- result.set(doc.id, childrenOf(doc.data));
72
+ const raw = data.links;
73
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
74
+ return result;
75
+ for (const [id, value] of Object.entries(raw)) {
76
+ if (!Array.isArray(value))
77
+ continue;
78
+ const targets = [];
79
+ for (const item of value) {
80
+ if (typeof item !== "object" || item === null)
81
+ continue;
82
+ const obj = item;
83
+ if (typeof obj.uid !== "string" || typeof obj.headerId !== "string")
84
+ continue;
85
+ if (targets.some((t) => t.uid === obj.uid && t.headerId === obj.headerId))
86
+ continue;
87
+ targets.push({ uid: obj.uid, headerId: obj.headerId });
88
+ }
89
+ if (targets.length > 0)
90
+ result.set(id, targets);
41
91
  }
42
92
  return result;
43
93
  }
@@ -7,9 +7,21 @@
7
7
  //
8
8
  // 子を持たなくなった要素の子一覧ドキュメントは消す(アプリのprojectLevelsと
9
9
  // 同じ扱い)。rootだけは空配列で残す。
10
- import { childrenOf } from "./levels.js";
10
+ //
11
+ // 期日・着手日(schedule)は children と同じドキュメントにあり、同じ1件の書き込みで
12
+ // 丸ごと書き直す。子を別の親へ移すときは日付も移し、消すときは外す。書くときは
13
+ // children に無いidを取り除く(古い版が取り残した分の掃除。docs/schedule.md §4)。
14
+ //
15
+ // マップ間リンク(links)も同じドキュメントにあり、日付と同じ扱いにする
16
+ // (docs/map-links.md §8)。ただしリンクを足す・外すツールは無い。移すときに運び、消すときに外す。
17
+ // header の linkCounts(リンク先ごとの件数の要約)は別ドキュメントなので、
18
+ // [withLinkSummary]で、変わるときだけ同じコミットへ足す。
19
+ import { buildTreeFromLevels, childrenOf, linksOf, scheduleOf, } from "./levels.js";
11
20
  export function levelsSnapshotFromDocs(docs) {
12
- return new Map(docs.map((d) => [d.id, { children: childrenOf(d.data), updateTime: d.updateTime }]));
21
+ return new Map(docs.map((d) => [
22
+ d.id,
23
+ { children: childrenOf(d.data), schedule: scheduleOf(d.data), links: linksOf(d.data), updateTime: d.updateTime },
24
+ ]));
13
25
  }
14
26
  /**
15
27
  * levels全件を読む。levels/rootが無いマップは空のマップとして扱う
@@ -72,11 +84,27 @@ export function subtreeIdsInLevels(snapshot, rootId) {
72
84
  walk(rootId);
73
85
  return result;
74
86
  }
87
+ /** list_elements 用。rootから辿れる要素の木に、各要素の期日・着手日を添える。 */
88
+ export function elementTreeOf(snapshot) {
89
+ const tree = buildTreeFromLevels(new Map([...snapshot].map(([id, level]) => [id, level.children])));
90
+ const attach = (nodes) => {
91
+ for (const node of nodes) {
92
+ const schedule = snapshot.get(node.parentId ?? "root")?.schedule.get(node.id);
93
+ if (schedule)
94
+ node.schedule = schedule;
95
+ attach(node.children);
96
+ }
97
+ };
98
+ attach(tree);
99
+ return tree;
100
+ }
75
101
  /** 編集中の子一覧。触ったものだけを複製して持ち、最後に差分を書き込みにする。 */
76
102
  class LevelsDraft {
77
103
  levelsPath;
78
104
  snapshot;
79
105
  edited = new Map();
106
+ editedSchedule = new Map();
107
+ editedLinks = new Map();
80
108
  removed = new Set();
81
109
  constructor(levelsPath, snapshot) {
82
110
  this.levelsPath = levelsPath;
@@ -87,15 +115,58 @@ class LevelsDraft {
87
115
  if (!list) {
88
116
  list = (this.snapshot.get(levelId)?.children ?? []).map((c) => ({ ...c }));
89
117
  this.edited.set(levelId, list);
118
+ this.editedSchedule.set(levelId, new Map(this.snapshot.get(levelId)?.schedule));
119
+ this.editedLinks.set(levelId, new Map(this.snapshot.get(levelId)?.links));
90
120
  this.removed.delete(levelId);
91
121
  }
92
122
  return list;
93
123
  }
124
+ /** [levelId]の子の日付。触ると、その子一覧も書き込みの対象になる。 */
125
+ schedule(levelId) {
126
+ this.children(levelId);
127
+ return this.editedSchedule.get(levelId);
128
+ }
129
+ /** [levelId]の子のマップ間リンク。触ると、その子一覧も書き込みの対象になる。 */
130
+ links(levelId) {
131
+ this.children(levelId);
132
+ return this.editedLinks.get(levelId);
133
+ }
94
134
  /** 子一覧ドキュメントごと消す(配下ごと削除される要素の分)。 */
95
135
  remove(levelId) {
96
136
  this.edited.delete(levelId);
137
+ this.editedSchedule.delete(levelId);
138
+ this.editedLinks.delete(levelId);
97
139
  this.removed.add(levelId);
98
140
  }
141
+ /**
142
+ * 書き込む schedule。children に無いidは除く。元も今も日付が無ければ
143
+ * undefined を返し、schedule のフィールドに触れない(日付を使わないマップの
144
+ * 書き込みを今までと同じに保つ)。
145
+ */
146
+ scheduleToWrite(levelId, children) {
147
+ const ids = new Set(children.map((c) => c.id));
148
+ const result = {};
149
+ for (const [id, entry] of this.editedSchedule.get(levelId) ?? []) {
150
+ if (ids.has(id))
151
+ result[id] = entry;
152
+ }
153
+ const hadSchedule = (this.snapshot.get(levelId)?.schedule.size ?? 0) > 0;
154
+ return hadSchedule || Object.keys(result).length > 0 ? result : undefined;
155
+ }
156
+ /**
157
+ * 書き込む links。children に無いidは除く。元も今もリンクが無ければ undefined を返し、
158
+ * links のフィールドに触れない(リンクを使わないマップの書き込みを今までと同じに保つ)。
159
+ */
160
+ linksToWrite(levelId, children) {
161
+ const ids = new Set(children.map((c) => c.id));
162
+ const result = {};
163
+ for (const [id, targets] of this.editedLinks.get(levelId) ?? []) {
164
+ if (ids.has(id))
165
+ result[id] = targets;
166
+ }
167
+ const hadLinks = (this.snapshot.get(levelId)?.links.size ?? 0) > 0;
168
+ return hadLinks || Object.keys(result).length > 0 ? result : undefined;
169
+ }
99
170
  toWrites() {
100
171
  const writes = [];
101
172
  const pathOf = (levelId) => `${this.levelsPath}/${levelId}`;
@@ -113,9 +184,16 @@ class LevelsDraft {
113
184
  }
114
185
  continue;
115
186
  }
187
+ const schedule = this.scheduleToWrite(levelId, children);
188
+ const links = this.linksToWrite(levelId, children);
189
+ const fields = {
190
+ children,
191
+ ...(schedule ? { schedule } : {}),
192
+ ...(links ? { links } : {}),
193
+ };
116
194
  writes.push(original
117
- ? { path: pathOf(levelId), fields: { children }, requireUpdateTime: original.updateTime }
118
- : { path: pathOf(levelId), fields: { children }, requireMissing: true });
195
+ ? { path: pathOf(levelId), fields, requireUpdateTime: original.updateTime }
196
+ : { path: pathOf(levelId), fields, requireMissing: true });
119
197
  }
120
198
  return writes;
121
199
  }
@@ -135,6 +213,33 @@ export function buildLevelsPatchWrites(levelsPath, snapshot, updates) {
135
213
  }
136
214
  return { writes: draft.toWrites(), found };
137
215
  }
216
+ /** set_schedule 用。見つからなかったidはfoundに入らない。同じ要素への指定は後が勝つ。 */
217
+ export function buildLevelsScheduleWrites(levelsPath, snapshot, updates) {
218
+ const index = indexOf(snapshot);
219
+ const draft = new LevelsDraft(levelsPath, snapshot);
220
+ const found = new Set();
221
+ for (const { elementId, due, start } of updates) {
222
+ const loc = index.get(elementId);
223
+ if (!loc)
224
+ continue;
225
+ const schedule = draft.schedule(loc.levelId);
226
+ const next = { ...schedule.get(elementId) };
227
+ if (due === null)
228
+ delete next.due;
229
+ else if (due !== undefined)
230
+ next.due = due;
231
+ if (start === null)
232
+ delete next.start;
233
+ else if (start !== undefined)
234
+ next.start = start;
235
+ if (next.due || next.start)
236
+ schedule.set(elementId, next);
237
+ else
238
+ schedule.delete(elementId);
239
+ found.add(elementId);
240
+ }
241
+ return { writes: draft.toWrites(), found };
242
+ }
138
243
  /**
139
244
  * auto_structure_thought / import_markdown_context 用。[forest]の最上位を
140
245
  * [targetParentId](nullなら最上位)の子の末尾へ足し、forest内で子を持つ
@@ -148,7 +253,10 @@ export function buildLevelsAppendWrites(levelsPath, snapshot, forest, targetPare
148
253
  done: isTodo ? node.done : false,
149
254
  });
150
255
  for (const node of forest) {
151
- draft.children(node.parentId ?? targetParentId ?? "root").push(entryOf(node));
256
+ const levelId = node.parentId ?? targetParentId ?? "root";
257
+ draft.children(levelId).push(entryOf(node));
258
+ if (node.schedule)
259
+ draft.schedule(levelId).set(node.id, node.schedule);
152
260
  }
153
261
  return draft.toWrites();
154
262
  }
@@ -172,7 +280,20 @@ export function buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParent
172
280
  const draft = new LevelsDraft(levelsPath, snapshot);
173
281
  const from = draft.children(loc.levelId);
174
282
  const [entry] = from.splice(from.findIndex((c) => c.id === elementId), 1);
175
- const to = draft.children(newParentId ?? "root");
283
+ // 日付も一緒に移す(同じ親の中の並べ替えなら、外して同じ所へ戻すだけ)
284
+ const fromSchedule = draft.schedule(loc.levelId);
285
+ const dates = fromSchedule.get(elementId);
286
+ fromSchedule.delete(elementId);
287
+ // リンクも一緒に移す
288
+ const fromLinks = draft.links(loc.levelId);
289
+ const linkTargets = fromLinks.get(elementId);
290
+ fromLinks.delete(elementId);
291
+ const toLevelId = newParentId ?? "root";
292
+ const to = draft.children(toLevelId);
293
+ if (dates)
294
+ draft.schedule(toLevelId).set(elementId, dates);
295
+ if (linkTargets)
296
+ draft.links(toLevelId).set(elementId, linkTargets);
176
297
  const insertAt = afterId === null
177
298
  ? 0
178
299
  : (() => {
@@ -200,8 +321,66 @@ export function buildLevelsDeleteWrites(levelsPath, snapshot, elementIds) {
200
321
  continue;
201
322
  const list = draft.children(loc.levelId);
202
323
  list.splice(list.findIndex((c) => c.id === id), 1);
324
+ draft.schedule(loc.levelId).delete(id);
325
+ draft.links(loc.levelId).delete(id);
203
326
  }
204
327
  for (const id of deletedIds)
205
328
  draft.remove(id);
206
329
  return { writes: draft.toWrites(), deletedIds };
207
330
  }
331
+ /**
332
+ * header の linkCounts に書く要約: リンク先のキー(`uid:headerId`)-> そのマップへリンクして
333
+ * いる要素の数。いま子一覧にある要素のリンクだけを数え、1つの要素が同じマップへ
334
+ * 複数張っていても1つと数える(アプリの linkCountsOf と同じ)。
335
+ */
336
+ export function linkCountsOfSnapshot(snapshot) {
337
+ const counts = {};
338
+ for (const level of snapshot.values()) {
339
+ const ids = new Set(level.children.map((c) => c.id));
340
+ for (const [id, targets] of level.links) {
341
+ if (!ids.has(id))
342
+ continue;
343
+ for (const key of new Set(targets.map((t) => `${t.uid}:${t.headerId}`))) {
344
+ counts[key] = (counts[key] ?? 0) + 1;
345
+ }
346
+ }
347
+ }
348
+ return counts;
349
+ }
350
+ /** [writes]を[snapshot]へ適用した後の状態(書き込みを組み立て直さず、要約の比較に使う)。 */
351
+ function applyWritesToSnapshot(levelsPath, snapshot, writes) {
352
+ const after = new Map(snapshot);
353
+ const prefix = `${levelsPath}/`;
354
+ for (const write of writes) {
355
+ if (!write.path.startsWith(prefix))
356
+ continue;
357
+ const levelId = write.path.slice(prefix.length);
358
+ if (write.delete) {
359
+ after.delete(levelId);
360
+ continue;
361
+ }
362
+ if (!write.fields)
363
+ continue;
364
+ const before = after.get(levelId);
365
+ after.set(levelId, {
366
+ children: write.fields.children !== undefined ? childrenOf(write.fields) : (before?.children ?? []),
367
+ schedule: before?.schedule ?? new Map(),
368
+ // links を書いていなければ、書き込み前のまま残る
369
+ links: write.fields.links !== undefined ? linksOf(write.fields) : (before?.links ?? new Map()),
370
+ updateTime: before?.updateTime,
371
+ });
372
+ }
373
+ return after;
374
+ }
375
+ /**
376
+ * [writes]でリンクの要約(linkCounts)が変わるときだけ、header への書き込みを足して返す
377
+ * (変わらなければ[writes]のまま)。levels と同じコミットで書く。header は別ドキュメントなので、
378
+ * levels の「同じドキュメントへ2件書かない」には触れない。
379
+ */
380
+ export function withLinkSummary(headerPath, levelsPath, snapshot, writes) {
381
+ const before = linkCountsOfSnapshot(snapshot);
382
+ const after = linkCountsOfSnapshot(applyWritesToSnapshot(levelsPath, snapshot, writes));
383
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
384
+ const changed = [...keys].some((k) => before[k] !== after[k]);
385
+ return changed ? [...writes, { path: headerPath, fields: { linkCounts: after } }] : writes;
386
+ }
@@ -30,6 +30,10 @@ export async function resolveMap(client, session, mapId) {
30
30
  }
31
31
  throw new MapNotFoundError(mapId);
32
32
  }
33
+ /** マップの header ドキュメントのパス。 */
34
+ export function headerPathOf(map) {
35
+ return `users/${map.ownerUid}/headers/${map.headerId}`;
36
+ }
33
37
  /** マップの中身(階層ごとの子一覧を配列で持つコレクション)のパス。 */
34
38
  export function levelsPathOf(map) {
35
39
  return `users/${map.ownerUid}/headers/${map.headerId}/levels`;
@@ -1,7 +1,24 @@
1
1
  import { newDocId } from "./idGen.js";
2
+ import { parseScheduleDate } from "./scheduleText.js";
2
3
  const LIST_PATTERN = /^(\s*)(?:[-*+]|\d+\.)\s+(.+)$/;
3
4
  const CHECKBOX_PATTERN = /^\[([ xX])\]\s*(.*)$/;
4
5
  const HEADING_PATTERN = /^(#{1,6})\s+(.*)$/;
6
+ // Obsidian Tasks の書き方(docs/schedule.md §7)。時刻付きは日付のあとに続ける。
7
+ const SCHEDULE_PATTERN = /\s*(📅|🛫)\s*(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)/gu;
8
+ /** 要素の1行目から 📅・🛫 の日付を取り出す。読めない日付は本文に残す。 */
9
+ function extractSchedule(detail) {
10
+ const schedule = {};
11
+ const rest = detail.replace(SCHEDULE_PATTERN, (whole, mark, text) => {
12
+ try {
13
+ schedule[mark === "📅" ? "due" : "start"] = parseScheduleDate(text);
14
+ return "";
15
+ }
16
+ catch {
17
+ return whole;
18
+ }
19
+ });
20
+ return schedule.due || schedule.start ? { detail: rest.trim(), schedule } : { detail };
21
+ }
5
22
  /**
6
23
  * Markdownテキストを解析して取り込める形の[MarkdownNode]の並びにする。
7
24
  *
@@ -48,15 +65,17 @@ export function parseMarkdownForImport(markdownText, { headingsAsNodes }) {
48
65
  done = checkboxMatch[1].toLowerCase() === "x";
49
66
  detail = checkboxMatch[2] ?? "";
50
67
  }
68
+ const extracted = extractSchedule(detail);
51
69
  currentNode = {
52
70
  id: newDocId(),
53
- detail,
71
+ detail: extracted.detail,
54
72
  rawIndent: indent,
55
73
  done,
56
74
  parentId: null,
57
75
  prevId: null,
58
76
  nextId: null,
59
77
  childId: null,
78
+ ...(extracted.schedule ? { schedule: extracted.schedule } : {}),
60
79
  };
61
80
  nodes.push(currentNode);
62
81
  continue;
@@ -0,0 +1,29 @@
1
+ const PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}))?$/;
2
+ const pad = (n) => String(n).padStart(2, "0");
3
+ export function formatScheduleDate(date) {
4
+ const d = date.at;
5
+ if (date.allDay) {
6
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
7
+ }
8
+ return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
9
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`);
10
+ }
11
+ export function parseScheduleDate(text) {
12
+ const m = PATTERN.exec(text.trim());
13
+ const invalid = () => new Error(`日付「${text}」を読めません。終日なら "YYYY-MM-DD"、時刻付きなら "YYYY-MM-DD HH:mm" で指定してください。`);
14
+ if (!m)
15
+ throw invalid();
16
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
17
+ const hasTime = m[4] !== undefined;
18
+ const [h, mi] = hasTime ? [Number(m[4]), Number(m[5])] : [0, 0];
19
+ if (h > 23 || mi > 59)
20
+ throw invalid();
21
+ const at = hasTime ? new Date(y, mo - 1, d, h, mi) : new Date(Date.UTC(y, mo - 1, d));
22
+ // 2/30 のような存在しない日付は、Date が翌月へ繰り越すので突き合わせて弾く
23
+ const [cy, cm, cd] = hasTime
24
+ ? [at.getFullYear(), at.getMonth() + 1, at.getDate()]
25
+ : [at.getUTCFullYear(), at.getUTCMonth() + 1, at.getUTCDate()];
26
+ if (cy !== y || cm !== mo || cd !== d)
27
+ throw invalid();
28
+ return { at, allDay: !hasTime };
29
+ }
@@ -15,6 +15,9 @@ export function encodeValue(value) {
15
15
  if (typeof value === "string") {
16
16
  return { stringValue: value };
17
17
  }
18
+ if (value instanceof Date) {
19
+ return { timestampValue: value.toISOString() };
20
+ }
18
21
  if (Array.isArray(value)) {
19
22
  return { arrayValue: { values: value.map(encodeValue) } };
20
23
  }
@@ -36,6 +39,9 @@ export function decodeValue(value) {
36
39
  return value.doubleValue;
37
40
  if ("stringValue" in value)
38
41
  return value.stringValue;
42
+ // RFC 3339。ナノ秒まで来ることがあるが、Dateはミリ秒までしか持たない
43
+ if ("timestampValue" in value)
44
+ return new Date(value.timestampValue);
39
45
  if ("arrayValue" in value) {
40
46
  return (value.arrayValue.values ?? []).map(decodeValue);
41
47
  }
@@ -10,9 +10,10 @@ import { registerImportMarkdownContext } from "./tools/importMarkdownContext.js"
10
10
  import { registerEditElement } from "./tools/editElement.js";
11
11
  import { registerDeleteElements } from "./tools/deleteElements.js";
12
12
  import { registerMoveElement } from "./tools/moveElement.js";
13
+ import { registerSetSchedule } from "./tools/setSchedule.js";
13
14
  const server = new McpServer({
14
15
  name: "matryoshka-mindmap",
15
- version: "0.1.3",
16
+ version: "0.2.0",
16
17
  }, {
17
18
  instructions: "このサーバーはリアルタイム同期を行わない。各ツールは呼び出された瞬間の" +
18
19
  "Firestoreの状態を返すだけで、アプリ側でユーザーが直接行った変更は" +
@@ -29,5 +30,6 @@ registerImportMarkdownContext(server);
29
30
  registerEditElement(server);
30
31
  registerDeleteElements(server);
31
32
  registerMoveElement(server);
33
+ registerSetSchedule(server);
32
34
  const transport = new StdioServerTransport();
33
35
  await server.connect(transport);
@@ -1,13 +1,18 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
+ import { assertDetailLengths } from "../domain/detailLimit.js";
3
4
  import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
4
5
  import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
5
6
  import { buildForestFromTree } from "../domain/appendForest.js";
6
7
  const treeNodeSchema = z.lazy(() => z.object({
7
- detail: z.string().min(1).describe("要素の本文"),
8
+ detail: z.string().min(1).describe("要素の本文(1要素5000文字まで)"),
8
9
  done: z.boolean().optional().describe("TODOマップのときのチェック状態(省略時false)"),
9
10
  children: z.array(treeNodeSchema).optional().describe("この要素の内側に入れ子にする子要素"),
10
11
  }));
12
+ /** [nodes]とその配下すべての本文。 */
13
+ function allDetails(nodes) {
14
+ return nodes.flatMap((node) => [node.detail, ...allDetails(node.children ?? [])]);
15
+ }
11
16
  export function registerAutoStructureThought(server) {
12
17
  server.tool("auto_structure_thought", "会話中にClaudeが考えた階層構造を、既存のマップに追加する。深さは3階層固定ではなく任意。" +
13
18
  "構造化のロジック(何をどう分解するか)はClaude側の判断で行い、このツールはFirestoreへの" +
@@ -21,6 +26,7 @@ export function registerAutoStructureThought(server) {
21
26
  .describe("この要素の内側(子)として追加する。マップの最上位に追加する場合はnullまたは省略"),
22
27
  nodes: z.array(treeNodeSchema).min(1).describe("追加する階層構造(ルートの配列)"),
23
28
  }, async ({ mapId, parentElementId, nodes }) => {
29
+ assertDetailLengths(allDetails(nodes));
24
30
  const { client, session } = getContext();
25
31
  const map = await resolveMap(client, session, mapId);
26
32
  assertCanEditMap(map, session);
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
3
  import { newDocId } from "../domain/idGen.js";
4
+ import { buildCreateMapWrites } from "../domain/headerOrder.js";
4
5
  export function registerCreateMap(server) {
5
6
  server.tool("create_map", "新しいマインドマップ(マップ)を1つ作成する。" +
6
7
  "list_mapsで確認して該当するマップが無いときだけ呼ぶこと。" +
@@ -10,19 +11,12 @@ export function registerCreateMap(server) {
10
11
  isTodo: z.boolean().describe("TODOマップ(要素にチェックボックスを付ける)にするかどうか"),
11
12
  }, async ({ title, isTodo }) => {
12
13
  const { client, session } = getContext();
13
- const existing = await client.listDocuments(`users/${session.uid}/headers`);
14
- const maxOrder = existing.reduce((max, doc) => Math.max(max, Number(doc.data.order ?? 0)), 0);
15
- // 自前でIDを採番し、header本体と空のlevels/rootを1回のcommitWritesに
16
- // まとめて作る(アプリの新規作成 header_add_model.dart と揃える)。
14
+ const headersPath = `users/${session.uid}/headers`;
15
+ // 振り直しは読んだ時点の updateTime を前提条件にするので、読んでから書くまでに
16
+ // ほかの端末で見出しが変わると失敗する。そのときは読み取りからやり直す。
17
+ // id はやり直しても同じものを使う(失敗した commit は何も書いていない)。
17
18
  const headerId = newDocId();
18
- const headerPath = `users/${session.uid}/headers/${headerId}`;
19
- await client.commitWrites([
20
- {
21
- path: headerPath,
22
- fields: { title, order: maxOrder + 1, isTodo, editors: [], viewers: [] },
23
- },
24
- { path: `${headerPath}/levels/root`, fields: { children: [] } },
25
- ]);
19
+ await client.runOptimistic(async () => buildCreateMapWrites(headersPath, await client.listDocuments(headersPath), headerId, title, isTodo));
26
20
  return {
27
21
  content: [
28
22
  {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
- import { ancestorDetailsInLevels, buildLevelsDeleteWrites, locateInLevels, subtreeIdsInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
4
- import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
3
+ import { ancestorDetailsInLevels, buildLevelsDeleteWrites, locateInLevels, subtreeIdsInLevels, readLevelsSnapshot, withLinkSummary, } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, headerPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
5
5
  export function registerDeleteElements(server) {
6
6
  server.tool("delete_elements", "既存マップから要素を削除する。指定した要素とその配下(子孫)を丸ごと削除する。" +
7
7
  "削除は取り返しがつかないため、まずconfirmedを省略(またはfalse)で呼び、" +
@@ -66,7 +66,8 @@ export function registerDeleteElements(server) {
66
66
  }
67
67
  const { writes, deletedIds } = buildLevelsDeleteWrites(levelsPath, snapshot, elementIds);
68
68
  levelsTotal = deletedIds.size;
69
- return writes;
69
+ // リンクの要約(header の linkCounts)が変わるときだけ、同じコミットで書き直す
70
+ return withLinkSummary(headerPathOf(map), levelsPath, snapshot, writes);
70
71
  });
71
72
  return {
72
73
  content: [
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
+ import { assertDetailLengths } from "../domain/detailLimit.js";
3
4
  import { ancestorDetailsInLevels, buildLevelsPatchWrites, readLevelsSnapshot } from "../domain/levelsOps.js";
4
5
  import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
5
6
  export function registerEditElement(server) {
@@ -7,8 +8,9 @@ export function registerEditElement(server) {
7
8
  "import_markdown_contextを、チェック状態の変更はupdate_task_statusを使うこと。", {
8
9
  mapId: z.string().describe("list_mapsで得たマップのID"),
9
10
  elementId: z.string().describe("list_elementsで得た要素のID"),
10
- detail: z.string().min(1).describe("書き換え後の本文"),
11
+ detail: z.string().min(1).describe("書き換え後の本文(1要素5000文字まで)"),
11
12
  }, async ({ mapId, elementId, detail }) => {
13
+ assertDetailLengths([detail]);
12
14
  const { client, session } = getContext();
13
15
  const map = await resolveMap(client, session, mapId);
14
16
  assertCanEditMap(map, session);
@@ -22,7 +24,10 @@ export function registerEditElement(server) {
22
24
  if (!found.has(elementId)) {
23
25
  throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
24
26
  }
25
- levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId));
27
+ // snapshotは書き込み前なので、末尾(この要素自身)だけ書き換え後の本文にする。
28
+ const chain = ancestorDetailsInLevels(snapshot, elementId);
29
+ chain[chain.length - 1] = detail;
30
+ levelsPathResult = formatPath(map.header.title, chain);
26
31
  return writes;
27
32
  });
28
33
  return {
@@ -3,6 +3,7 @@ import { getContext } from "../mcpContext.js";
3
3
  import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
4
4
  import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
5
5
  import { parseMarkdownForImport } from "../domain/markdownParser.js";
6
+ import { assertDetailLengths } from "../domain/detailLimit.js";
6
7
  export function registerImportMarkdownContext(server) {
7
8
  server.tool("import_markdown_context", "任意の深さのMarkdown(インデントで階層を表現したリスト、見出しは`#`)を組み立てて、" +
8
9
  "既存のマップに流し込む。見出しも1つの要素として取り込む。" +
@@ -10,7 +11,7 @@ export function registerImportMarkdownContext(server) {
10
11
  "チェック状態も反映されない(アプリ本体の挙動と同じ)。" +
11
12
  "対象は既存マップのみ(新規作成が必要ならcreate_mapを先に呼ぶ)。", {
12
13
  mapId: z.string().describe("list_mapsで得たマップのID"),
13
- markdown: z.string().min(1).describe("取り込むMarkdownテキスト"),
14
+ markdown: z.string().min(1).describe("取り込むMarkdownテキスト(1要素5000文字まで。超える要素があれば何も書かない)。要素の1行目に「📅 2026-10-15」(期日)・「🛫 2026-10-12」(着手日)と書くと日付として取り込む。時刻付きは「📅 2026-10-15 09:00」(このPCの現地時刻)"),
14
15
  parentElementId: z
15
16
  .string()
16
17
  .nullable()
@@ -26,6 +27,7 @@ export function registerImportMarkdownContext(server) {
26
27
  if (parsed.nodes.length === 0) {
27
28
  throw new Error("Markdownからリスト項目・見出しが見つかりませんでした。");
28
29
  }
30
+ assertDetailLengths(parsed.nodes.map((node) => node.detail));
29
31
  let levelsPathResult = "";
30
32
  await client.runOptimistic(async () => {
31
33
  const snapshot = await readLevelsSnapshot(client, levelsPath);
@@ -1,13 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
3
  import { resolveMap, levelsPathOf } from "../domain/mapResolver.js";
4
- import { buildTreeFromLevels, readAllLevelDocs, } from "../domain/levels.js";
4
+ import { elementTreeOf, readLevelsSnapshot } from "../domain/levelsOps.js";
5
+ import { formatScheduleDate } from "../domain/scheduleText.js";
5
6
  // ElementNodeの`done`を`checked`という名前で公開する(仕様書の用語に合わせる)。
6
7
  function toChecked(nodes) {
7
8
  return nodes.map((n) => ({
8
9
  id: n.id,
9
10
  detail: n.detail,
10
11
  checked: n.done,
12
+ // 日付は付いているときだけ返す(docs/schedule.md §3 MCP)
13
+ ...(n.schedule?.due ? { due: formatScheduleDate(n.schedule.due) } : {}),
14
+ ...(n.schedule?.start ? { start: formatScheduleDate(n.schedule.start) } : {}),
11
15
  children: toChecked(n.children),
12
16
  }));
13
17
  }
@@ -37,21 +41,24 @@ class AiReadDeniedError extends Error {
37
41
  }
38
42
  }
39
43
  export function registerListElements(server) {
40
- server.tool("list_elements", "指定したマップ内の全要素と親子関係、チェック状態(checked)を返す。" +
44
+ server.tool("list_elements", "指定したマップ内の全要素と親子関係、チェック状態(checked)、期日(due)・着手日(start)を返す。" +
45
+ "dueとstartは付いている要素にだけあり、終日なら\"YYYY-MM-DD\"、時刻付きなら" +
46
+ "\"YYYY-MM-DD HH:mm\"(このPCの現地時刻)。" +
41
47
  "checkedは各要素にFirestoreへ実際に保存されている生の値であり、" +
42
48
  "「祖先がチェック済みなら配下も完了とみなす」という加工は行っていない。" +
43
49
  "そう解釈したい場合は、返ってきたツリーを見てこちら(呼び出す側)で判断すること。" +
44
- "マップの中身には秘匿情報が含まれ得るため、初めて読むマップでは呼び出し前に" +
45
- "ユーザー本人の許可が必要。confirmed: trueを付けずに呼ぶと、このツールは" +
46
- "実データを返す代わりに確認を促すメッセージだけを返すので、その指示に従って" +
47
- "ユーザーに直接確認してからconfirmed: trueで呼び直すこと。" +
50
+ "呼ぶ前にユーザーへ確認しないこと。まずconfirmedを付けずに呼ぶ。" +
51
+ "アプリでAIからの読み取りを「常に許可」にしたマップは、そのまま実データを返す。" +
52
+ "それ以外のマップは、実データの代わりに確認を促すメッセージだけを返すので、" +
53
+ "そのときに限りユーザーに直接確認してからconfirmed: trueで呼び直すこと。" +
48
54
  "アプリ側での直接の編集はこのツールを呼ぶまで反映されないため、" +
49
55
  "ユーザーから進捗や状態を尋ねられたときは記憶に頼らず必ず呼び直すこと。", {
50
56
  mapId: z.string().describe("list_mapsで得たマップのID"),
51
57
  confirmed: z
52
58
  .boolean()
53
59
  .optional()
54
- .describe("ユーザー本人に直接「中身を確認してよいか」と尋ね、同意を得てからtrueを付けて呼ぶこと。" +
60
+ .describe("最初は付けない。確認を促すメッセージが返ったときだけ、ユーザー本人に直接" +
61
+ "「中身を確認してよいか」と尋ね、同意を得てからtrueを付けて呼び直す。" +
55
62
  "確認前にtrueを付けてはならない。"),
56
63
  }, async ({ mapId, confirmed }) => {
57
64
  const { client, session } = getContext();
@@ -78,8 +85,7 @@ export function registerListElements(server) {
78
85
  }
79
86
  }
80
87
  // levels/rootが無いマップは空のマップとして返す(アプリで開いてもrootは作られない)。
81
- const levelsById = await readAllLevelDocs(client, levelsPathOf(map));
82
- const tree = buildTreeFromLevels(levelsById);
88
+ const tree = elementTreeOf(await readLevelsSnapshot(client, levelsPathOf(map)));
83
89
  return {
84
90
  content: [
85
91
  {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
- import { ancestorDetailsInLevels, buildLevelsMoveWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
4
- import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
3
+ import { ancestorDetailsInLevels, buildLevelsMoveWrites, locateInLevels, readLevelsSnapshot, withLinkSummary, } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, headerPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
5
5
  export function registerMoveElement(server) {
6
6
  server.tool("move_element", "既存マップの要素1件(配下ごと)を、同じマップ内の別の場所へ移動する。" +
7
7
  "階層をまたいだ移動(昇格・降格)も、同じ階層内の並べ替えも、これ1つで行える。", {
@@ -34,7 +34,8 @@ export function registerMoveElement(server) {
34
34
  }
35
35
  const writes = buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParentElementId, afterElementId);
36
36
  levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, newParentElementId));
37
- return writes;
37
+ // リンクの要約(header の linkCounts)が変わるときだけ、同じコミットで書き直す
38
+ return withLinkSummary(headerPathOf(map), levelsPath, snapshot, writes);
38
39
  });
39
40
  return {
40
41
  content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
@@ -0,0 +1,54 @@
1
+ import { z } from "zod";
2
+ import { getContext } from "../mcpContext.js";
3
+ import { ancestorDetailsInLevels, buildLevelsScheduleWrites, readLevelsSnapshot, } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, formatPath, assertCanEditMap } from "../domain/mapResolver.js";
5
+ import { parseScheduleDate } from "../domain/scheduleText.js";
6
+ const dateSchema = z
7
+ .string()
8
+ .nullable()
9
+ .optional()
10
+ .describe("終日なら\"YYYY-MM-DD\"、時刻付きなら\"YYYY-MM-DD HH:mm\"(このPCの現地時刻)。" +
11
+ "nullで外す。省略するとその日付には触れない");
12
+ export function registerSetSchedule(server) {
13
+ server.tool("set_schedule", "要素の期日(due)と着手日(start)を付ける・外す。1件でも複数件でもよい。" +
14
+ "親の日付を子に引き継ぐことはしない(親と子の日付は独立している)。" +
15
+ "今の日付はlist_elementsで確認できる。", {
16
+ mapId: z.string().describe("list_mapsで得たマップのID"),
17
+ updates: z
18
+ .array(z.object({
19
+ elementId: z.string().describe("list_elementsで得た要素のID"),
20
+ due: dateSchema,
21
+ start: dateSchema,
22
+ }))
23
+ .min(1),
24
+ }, async ({ mapId, updates }) => {
25
+ // 日付の書き方の誤りは、何も読まないうちに返す
26
+ const parsed = updates.map((u) => ({
27
+ elementId: u.elementId,
28
+ due: u.due == null ? u.due : parseScheduleDate(u.due),
29
+ start: u.start == null ? u.start : parseScheduleDate(u.start),
30
+ }));
31
+ const { client, session } = getContext();
32
+ const map = await resolveMap(client, session, mapId);
33
+ assertCanEditMap(map, session);
34
+ const levelsPath = levelsPathOf(map);
35
+ let results = [];
36
+ await client.runOptimistic(async () => {
37
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
38
+ const { writes, found } = buildLevelsScheduleWrites(levelsPath, snapshot, parsed);
39
+ results = updates.map((u) => ({
40
+ elementId: u.elementId,
41
+ path: found.has(u.elementId)
42
+ ? formatPath(map.header.title, ancestorDetailsInLevels(snapshot, u.elementId))
43
+ : null,
44
+ ...(u.due !== undefined ? { due: u.due } : {}),
45
+ ...(u.start !== undefined ? { start: u.start } : {}),
46
+ found: found.has(u.elementId),
47
+ }));
48
+ return writes;
49
+ });
50
+ return {
51
+ content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }],
52
+ };
53
+ });
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mmm-mcp-server",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "mcpName": "io.github.nhorie-canal/mmm-mcp-server",
5
5
  "description": "Matryoshka Mind Map の自分のデータをClaudeから直接読み書きするMCPサーバー",
6
6
  "license": "MIT",