mmm-mcp-server 0.1.1 → 0.1.3
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 +228 -0
- package/README.md +2 -0
- package/dist/src/domain/appendForest.js +0 -50
- package/dist/src/domain/levels.js +3 -180
- package/dist/src/domain/levelsOps.js +10 -2
- package/dist/src/domain/mapResolver.js +1 -4
- package/dist/src/domain/markdownParser.js +20 -21
- package/dist/src/firestore/restClient.js +0 -39
- package/dist/src/server.js +1 -1
- package/dist/src/tools/autoStructureThought.js +9 -48
- package/dist/src/tools/createMap.js +3 -7
- package/dist/src/tools/deleteElements.js +17 -87
- package/dist/src/tools/editElement.js +11 -44
- package/dist/src/tools/importMarkdownContext.js +9 -53
- package/dist/src/tools/listElements.js +2 -2
- package/dist/src/tools/moveElement.js +8 -46
- package/dist/src/tools/updateTaskStatus.js +15 -59
- package/package.json +3 -3
- package/dist/src/domain/body.js +0 -127
- package/dist/src/domain/deleteElements.js +0 -85
- package/dist/src/domain/levelsMode.js +0 -24
- package/dist/src/domain/moveElement.js +0 -93
package/README.en.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# mmm-mcp-server
|
|
2
|
+
|
|
3
|
+
[日本語](README.md) | English
|
|
4
|
+
|
|
5
|
+
A tool that lets you read and write your own maps in Matryoshka Mind Map (a mind map / TODO app) directly from conversations with AI agents. Because it is built on the Model Context Protocol (MCP) open standard, it works with any MCP-compatible AI agent, not just Claude Desktop and Claude Code (**tested on Claude Desktop and Claude Code**; for other clients, see their documentation on how to register an MCP server).
|
|
6
|
+
|
|
7
|
+
- Say "Show me my current maps" and it lists your maps.
|
|
8
|
+
- Turn ideas from your conversation directly into a hierarchical mind map.
|
|
9
|
+
- Just say "I finished task X" to check it off your TODO list.
|
|
10
|
+
- Import long notes or Markdown straight into a map.
|
|
11
|
+
|
|
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
|
+
|
|
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.
|
|
15
|
+
|
|
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
|
+
>
|
|
18
|
+
> **Supported OS**: It should work on both macOS and Windows. It runs purely on Node.js and does not depend on OS-specific features. However, **it has only been tested on real machines running macOS.** If you notice anything while trying it on Windows, please let us know via an Issue in this repository. Steps that differ by OS (opening the terminal and installing Node.js) are described separately for macOS and Windows. All other commands are the same on both.
|
|
19
|
+
|
|
20
|
+
## 1. Prerequisites
|
|
21
|
+
|
|
22
|
+
- A Matryoshka Mind Map account (**anonymous-only accounts cannot be used.** Make sure you can log in with Google, Apple, or an email address. You can also link one later with the "Link email address" feature in the app).
|
|
23
|
+
- An MCP-compatible AI agent (tested with Claude Desktop / Claude Code; other MCP-compatible clients should generally work too).
|
|
24
|
+
- Node.js (the runtime that runs the tool; you will install it in Step 3).
|
|
25
|
+
|
|
26
|
+
## 2. Open the Terminal
|
|
27
|
+
|
|
28
|
+
### On macOS
|
|
29
|
+
|
|
30
|
+
1. Press `Command` + `Space` on your keyboard (opens Spotlight search).
|
|
31
|
+
2. Type "Terminal".
|
|
32
|
+
3. Click "Terminal" in the results, or press Enter.
|
|
33
|
+
|
|
34
|
+
### On Windows
|
|
35
|
+
|
|
36
|
+
1. Press the `Windows` key on your keyboard (opens Start menu search).
|
|
37
|
+
2. Type "PowerShell".
|
|
38
|
+
3. Click "Windows PowerShell" in the results, or press Enter.
|
|
39
|
+
|
|
40
|
+
Once a dark (or light) window opens, you are ready. For the rest of the steps, paste the commands into this window one line at a time and press Enter (the commands themselves are the same on macOS and Windows).
|
|
41
|
+
|
|
42
|
+
## 3. Install Node.js
|
|
43
|
+
|
|
44
|
+
Check whether Node.js is already installed with this command:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node --version
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If a version number of `v18` or higher is displayed (e.g., `v20.11.0`), it is already installed. If you see an error like "command not found", download the installer from:
|
|
51
|
+
|
|
52
|
+
https://nodejs.org/
|
|
53
|
+
|
|
54
|
+
The site shows two large buttons. Click the one labeled **"LTS"** to download (the `.pkg` installer for macOS or the `.msi` installer for Windows is chosen automatically for your OS). Double-click the downloaded file and follow the on-screen instructions (on macOS: "Continue" → "Agree" → "Install"; on Windows: "Next" → "I accept..." → "Install").
|
|
55
|
+
|
|
56
|
+
When the installation is finished, go back to the terminal and check again:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
node --version
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 4. Log In
|
|
63
|
+
|
|
64
|
+
Choose one of the following three options based on how you usually log in to the Matryoshka Mind Map app. **None of these methods require any setup on your part beforehand.** The first run downloads the tool, so it may take a moment.
|
|
65
|
+
|
|
66
|
+
### If you log in with Google
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx -y -p mmm-mcp-server mmm-login login --method google
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Your browser opens automatically; log in with the Google account you usually use.
|
|
73
|
+
|
|
74
|
+
### If you log in with Apple
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx -y -p mmm-mcp-server mmm-login login --method apple
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Your browser opens automatically; log in with your Apple ID.
|
|
81
|
+
|
|
82
|
+
### If you log in with an email address and password
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx -y -p mmm-mcp-server mmm-login login --method email
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
You will be asked for your email address and password (the password is not shown on screen).
|
|
89
|
+
|
|
90
|
+
> **If you get a "wrong password" error**, it does not necessarily mean you mistyped your password. If your account signs in to the app with Google or Apple and has no email/password credentials at all, you get the same error. If that may be the case, try the "Google" or "Apple" steps above.
|
|
91
|
+
|
|
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
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx -y -p mmm-mcp-server mmm-login status
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
shows your login status. To log out:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx -y -p mmm-mcp-server mmm-login logout
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 5. Register It with Your AI Agent
|
|
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.
|
|
107
|
+
|
|
108
|
+
### Claude Code
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
claude mcp add mmm -- npx -y mmm-mcp-server
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Claude Desktop
|
|
115
|
+
|
|
116
|
+
Open the Claude Desktop configuration file (`claude_desktop_config.json`) and add the following under `mcpServers`:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"mcpServers": {
|
|
121
|
+
"mmm": {
|
|
122
|
+
"command": "npx",
|
|
123
|
+
"args": ["-y", "mmm-mcp-server"]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Save the file and restart Claude Desktop.
|
|
130
|
+
|
|
131
|
+
## 6. Try It Out
|
|
132
|
+
|
|
133
|
+
Try talking to your AI agent like this:
|
|
134
|
+
|
|
135
|
+
- "Show me my Matryoshka maps."
|
|
136
|
+
- "Add a task called [task] to the [map name] map."
|
|
137
|
+
- "I finished the [task] task in the [map name] map."
|
|
138
|
+
- "I'll paste my notes in Markdown; import them into the [map name] map."
|
|
139
|
+
|
|
140
|
+
**Only the first time the contents of a map (the element text) are read**, the tool asks for your permission before returning the actual contents. This is out of consideration for personal information that the contents may contain.
|
|
141
|
+
|
|
142
|
+
Instead of the contents, the tool returns only an instruction: "confirm directly with the user, then call again with `confirmed: true`." This is best-effort: the server has no way to verify whether the agent actually asked you, so whether the confirmation happens properly depends on the AI agent you use. Once confirmed, the same map is not confirmed again while that server is running (roughly one conversation).
|
|
143
|
+
|
|
144
|
+
(Previously, MCP's standard confirmation dialog, "elicitation", was also used to enforce this. However, some clients advertised support without actually showing a dialog, so contents became unreadable without the user ever seeing a confirmation screen. It was removed on 2026-09-03 in favor of the approach above.)
|
|
145
|
+
|
|
146
|
+
## Keeping Your Maps Tidy
|
|
147
|
+
|
|
148
|
+
Letting an AI agent write your maps is convenient, but left alone the maps tend to become hard to read. Paste the following into the instruction file your AI agent loads every time (for Claude Code, `~/.claude/CLAUDE.md`) to keep the writing style consistent:
|
|
149
|
+
|
|
150
|
+
```markdown
|
|
151
|
+
## How to write in mmm
|
|
152
|
+
|
|
153
|
+
- Keep each element short enough to read in one line. Do not pack in explanations.
|
|
154
|
+
If you want to keep reasons or notes, put them one level down as child elements.
|
|
155
|
+
- Put a URL in its own element. Do not mix it with text like "Docs here: https://...".
|
|
156
|
+
Create an element named "Docs" and put only the URL as its child.
|
|
157
|
+
- Order reminder items chronologically. Name them by when to do them, such as
|
|
158
|
+
"Right after release", "In 2 weeks", or "When X happens".
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The reasons:
|
|
162
|
+
|
|
163
|
+
- **One line per element**: Matryoshka shows elements nested inside each other, so long elements hurt readability and lose the benefit of drilling down through the hierarchy.
|
|
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
|
+
- **Chronological order**: Compared with ordering by task name, checking items off in order makes it easier to notice anything you missed.
|
|
166
|
+
|
|
167
|
+
## Troubleshooting
|
|
168
|
+
|
|
169
|
+
| Symptom | Cause / Solution |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `node --version` says "command not found" | Node.js from Step 3 is not installed yet. After installing, close the terminal and open it again. |
|
|
172
|
+
| "Wrong password" when logging in | See the note in Step 4. Try logging in with Google or Apple instead. |
|
|
173
|
+
| The agent does not operate on your maps when asked | Check that the registration in Step 5 is correct. Claude Desktop needs a restart after registration. |
|
|
174
|
+
| The agent will not show map contents / stops at the confirmation | Check that you answered "yes" when asked for permission to read. |
|
|
175
|
+
| The AI cannot load a large map | With several hundred elements or more, the `list_elements` response gets large and may not fit in one go. Some AI agents handle this by saving it to a file and reading only the parts they need. If it happens often, split the map (swiping left on a top-level element turns it and everything under it into a separate map). |
|
|
176
|
+
|
|
177
|
+
## Advanced Example: Recording Tasks per Folder Automatically
|
|
178
|
+
|
|
179
|
+
What follows is **just one example of how it can be used**. It is not a standard workflow recommended for everyone, but shows what becomes possible with this kind of setup. Feel free to ignore it if it does not suit you.
|
|
180
|
+
|
|
181
|
+
**Goal**: Pair each folder (project) you work on in Claude Code with a map of the same name, and have the AI agent record and update tasks that come up during work in that map in real time, without asking.
|
|
182
|
+
|
|
183
|
+
> Setting (2) below uses Claude Code's "global CLAUDE.md", an instruction file that is always loaded. If your AI agent has a similar feature for instructions loaded every time, the same idea should apply.
|
|
184
|
+
|
|
185
|
+
### Setup
|
|
186
|
+
|
|
187
|
+
**(1) Re-register the mmm server so it is available in every folder**
|
|
188
|
+
|
|
189
|
+
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
|
+
|
|
191
|
+
```bash
|
|
192
|
+
claude mcp add mmm --scope user -- npx -y mmm-mcp-server
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**(2) Write the workflow rules in your global CLAUDE.md (`~/.claude/CLAUDE.md`)**
|
|
196
|
+
|
|
197
|
+
For example, add something like this:
|
|
198
|
+
|
|
199
|
+
```markdown
|
|
200
|
+
## Task management with mmm
|
|
201
|
+
|
|
202
|
+
Record tasks that come up in the working folder in mmm (the Matryoshka Mind Map
|
|
203
|
+
integration) and manage them in real time.
|
|
204
|
+
|
|
205
|
+
- Use the working folder name as the map title.
|
|
206
|
+
- Check with list_maps whether a map with the same name exists. If not, create it
|
|
207
|
+
right away with create_map(title=folder name, isTodo=true) without confirming
|
|
208
|
+
the title (confirmation is not required for this workflow only).
|
|
209
|
+
- Calling list_elements to see existing tasks asks for confirmation only the first
|
|
210
|
+
time the map is read in that process. After that, add and update tasks in real
|
|
211
|
+
time with auto_structure_thought / update_task_status.
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Why this works
|
|
215
|
+
|
|
216
|
+
- `create_map` takes the title directly as an argument; "confirm in the conversation before calling" is only a recommended behavior for AI agents written in the tool description. A more specific instruction in CLAUDE.md takes priority, so maps can be created automatically without confirmation.
|
|
217
|
+
- Only `list_elements`, which reads map contents, has confirmation enforced on the server side out of consideration for confidential information (see "Try It Out" above). This does not change with the per-folder workflow: confirmation happens once, the first time a map is read in that process.
|
|
218
|
+
|
|
219
|
+
### Caveats
|
|
220
|
+
|
|
221
|
+
- Maps are created automatically for every folder, so work-log style maps will mix in with your carefully curated mind maps. Some people may find this cluttered. Adjust it to suit you, for example by using a separate account or limiting it to certain folders.
|
|
222
|
+
- Skipping confirmation for `create_map` applies only when you spell out this workflow in your own CLAUDE.md. Without any setup, the agent confirms in the conversation before creating a map, as usual.
|
|
223
|
+
|
|
224
|
+
## About This Tool
|
|
225
|
+
|
|
226
|
+
- Your login credentials (refresh token) are stored only on your computer (macOS: `~/.config/mmm-mcp/credentials.json`, Windows: `C:\Users\<username>\.config\mmm-mcp\credentials.json`) and are never sent anywhere else.
|
|
227
|
+
- Your map contents are read from and written to your Firebase account directly, without going through any third-party server, including the developer's (only when logging in with Apple, the last step of login goes through a signing-only relay server, but your map contents never pass through it).
|
|
228
|
+
- There is no always-running process; it runs only while you are in a conversation with your AI agent.
|
package/README.md
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { newDocId } from "./idGen.js";
|
|
2
|
-
import { lastChildOf } from "./body.js";
|
|
3
2
|
/** ネストしたツリー入力を、ID採番済み・prev/next/parent/child関係を組んだ配列に変換する。 */
|
|
4
3
|
export function buildForestFromTree(roots) {
|
|
5
4
|
const all = [];
|
|
@@ -31,52 +30,3 @@ export function buildForestFromTree(roots) {
|
|
|
31
30
|
build(roots, null);
|
|
32
31
|
return all;
|
|
33
32
|
}
|
|
34
|
-
/**
|
|
35
|
-
* 組み立てたforest(森)を、既存の要素の[targetParentId]配下の末尾に連結する
|
|
36
|
-
* ためのFirestore書き込み一覧を作る。auto_structure_thought /
|
|
37
|
-
* import_markdown_context の両方から共通で使う。
|
|
38
|
-
*
|
|
39
|
-
* `isTodo`がfalseのマップでは、doneは常にfalseとして書き込む
|
|
40
|
-
* (MatryoshkaModel.importMarkdownと同じ挙動)。
|
|
41
|
-
*/
|
|
42
|
-
export function buildAppendWrites(bodiesPath, existingBodies, forest, targetParentId, isTodo) {
|
|
43
|
-
const roots = forest.filter((n) => n.parentId === null);
|
|
44
|
-
const last = lastChildOf(existingBodies, targetParentId);
|
|
45
|
-
if (roots.length > 0) {
|
|
46
|
-
roots[0].prevId = last;
|
|
47
|
-
}
|
|
48
|
-
const writes = forest.map((node) => ({
|
|
49
|
-
path: `${bodiesPath}/${node.id}`,
|
|
50
|
-
fields: {
|
|
51
|
-
detail: node.detail,
|
|
52
|
-
parent: node.parentId ?? targetParentId,
|
|
53
|
-
prev: node.prevId,
|
|
54
|
-
next: node.nextId,
|
|
55
|
-
child: node.childId,
|
|
56
|
-
done: isTodo ? node.done : false,
|
|
57
|
-
},
|
|
58
|
-
}));
|
|
59
|
-
if (roots.length > 0) {
|
|
60
|
-
if (last !== null) {
|
|
61
|
-
// 末尾要素のnextを書き換える。読み取り後に他の操作(並行するappendや
|
|
62
|
-
// アプリ操作)がこの要素を書き換えていた場合、requireUpdateTimeにより
|
|
63
|
-
// FAILED_PRECONDITIONで失敗する(呼び出し側はrunOptimisticで読み取り
|
|
64
|
-
// からやり直す)。
|
|
65
|
-
const lastBody = existingBodies.find((b) => b.id === last);
|
|
66
|
-
writes.push({
|
|
67
|
-
path: `${bodiesPath}/${last}`,
|
|
68
|
-
fields: { next: roots[0].id },
|
|
69
|
-
requireUpdateTime: lastBody?.updateTime,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
else if (targetParentId !== null) {
|
|
73
|
-
const parentBody = existingBodies.find((b) => b.id === targetParentId);
|
|
74
|
-
writes.push({
|
|
75
|
-
path: `${bodiesPath}/${targetParentId}`,
|
|
76
|
-
fields: { child: roots[0].id },
|
|
77
|
-
requireUpdateTime: parentBody?.updateTime,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return { writes, createdIds: forest.map((n) => n.id) };
|
|
82
|
-
}
|
|
@@ -7,15 +7,7 @@
|
|
|
7
7
|
// levelIdはマップ最上位が'root'、それ以外はその要素自身のid。配列の並び順が
|
|
8
8
|
// そのまま兄弟順。子を持つ要素だけドキュメントが存在する(無ければ葉)。
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
// が無いマップに対する操作は、アプリで一度開いてもらうよう案内して止める。
|
|
12
|
-
export class LevelsNotInitializedError extends Error {
|
|
13
|
-
constructor(title) {
|
|
14
|
-
super(`「${title}」はまだ新形式(levels)のデータがありません。` +
|
|
15
|
-
"お手数ですが、アプリでこのマップを一度開いてから、もう一度お試しください。");
|
|
16
|
-
this.name = "LevelsNotInitializedError";
|
|
17
|
-
}
|
|
18
|
-
}
|
|
10
|
+
// 書き込みは levelsOps.ts が行う。ここはデータ形状の解釈と読み取りだけを持つ。
|
|
19
11
|
function toEntry(raw) {
|
|
20
12
|
if (typeof raw !== "object" || raw === null)
|
|
21
13
|
return null;
|
|
@@ -49,33 +41,9 @@ export async function readAllLevelDocs(client, levelsPath) {
|
|
|
49
41
|
}
|
|
50
42
|
return result;
|
|
51
43
|
}
|
|
52
|
-
/** levels/rootが無ければ、アプリで開いてもらうよう案内して止める。 */
|
|
53
|
-
export function assertLevelsInitialized(levelsById, mapTitle) {
|
|
54
|
-
if (!levelsById.has("root")) {
|
|
55
|
-
throw new LevelsNotInitializedError(mapTitle);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* [levelId]のlevelsドキュメントが存在するかだけを軽く確認する。
|
|
60
|
-
* 「levels/rootが無ければ止める」ような存在チェックのためだけに
|
|
61
|
-
* readAllLevelDocs(コレクション全体のページング読み取り)を使うのは、
|
|
62
|
-
* 子を多く持つ大きなマップほど無駄が大きい。1件のgetDocumentで足りる
|
|
63
|
-
* 場面(list_elementsのようにツリー全体が実際に必要な場合を除く)は
|
|
64
|
-
* こちらを使うこと。
|
|
65
|
-
*/
|
|
66
|
-
export async function levelsDocExists(client, levelsPath, levelId) {
|
|
67
|
-
return (await client.getDocument(`${levelsPath}/${levelId}`)) !== null;
|
|
68
|
-
}
|
|
69
|
-
/** [levelsDocExists]の結果を見て、levels/rootが無ければ止める。 */
|
|
70
|
-
export function assertLevelsRootExists(rootExists, mapTitle) {
|
|
71
|
-
if (!rootExists) {
|
|
72
|
-
throw new LevelsNotInitializedError(mapTitle);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
44
|
/**
|
|
76
|
-
* 'root'から再帰的にlevels
|
|
77
|
-
*
|
|
78
|
-
* 分断の復元は不要。循環参照だけは防御的に検出して打ち切る。
|
|
45
|
+
* 'root'から再帰的にlevelsを展開してツリーを組み立てる。levelsは配列そのものが
|
|
46
|
+
* 順序を表すため、並び順の再構築は不要。循環参照だけは防御的に検出して打ち切る。
|
|
79
47
|
*/
|
|
80
48
|
export function buildTreeFromLevels(levelsById) {
|
|
81
49
|
function walk(levelId, parentId, ancestry) {
|
|
@@ -95,148 +63,3 @@ export function buildTreeFromLevels(levelsById) {
|
|
|
95
63
|
}
|
|
96
64
|
return walk("root", null, new Set());
|
|
97
65
|
}
|
|
98
|
-
/**
|
|
99
|
-
* forest(bodies用のbuildAppendWritesと同じ入力)から、levels側の書き込み
|
|
100
|
-
* 一覧を組み立てる。forest内で子を持つノードごとに levels/{id} を新規作成し、
|
|
101
|
-
* forestの最上位(ルート)群は挿入先(targetParentId ?? 'root')の既存配列へ
|
|
102
|
-
* 追記する。[targetHasExistingChildren]は、bodies側でlastChildOfが返した
|
|
103
|
-
* 値がnullでなかったか(=挿入先が既に子を持っていたか)をそのまま渡すこと。
|
|
104
|
-
*/
|
|
105
|
-
export function buildAppendLevelWrites(levelsPath, forest, targetParentId, targetHasExistingChildren, isTodo) {
|
|
106
|
-
const writes = [];
|
|
107
|
-
const byParent = new Map();
|
|
108
|
-
for (const node of forest) {
|
|
109
|
-
const list = byParent.get(node.parentId) ?? [];
|
|
110
|
-
list.push(node);
|
|
111
|
-
byParent.set(node.parentId, list);
|
|
112
|
-
}
|
|
113
|
-
const entryOf = (node) => ({
|
|
114
|
-
id: node.id,
|
|
115
|
-
detail: node.detail,
|
|
116
|
-
done: isTodo ? node.done : false,
|
|
117
|
-
});
|
|
118
|
-
// forest内で子を持つノードは全て新規作成なので、既存のlevels/{id}と
|
|
119
|
-
// 衝突することはない(既存ドキュメントは無いはず)。
|
|
120
|
-
for (const [parentId, children] of byParent) {
|
|
121
|
-
if (parentId === null)
|
|
122
|
-
continue; // ルート群は下で挿入先へ追記する
|
|
123
|
-
writes.push({
|
|
124
|
-
path: `${levelsPath}/${parentId}`,
|
|
125
|
-
fields: { children: children.map(entryOf) },
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
const roots = byParent.get(null) ?? [];
|
|
129
|
-
if (roots.length > 0) {
|
|
130
|
-
const targetLevelId = targetParentId ?? "root";
|
|
131
|
-
if (targetHasExistingChildren) {
|
|
132
|
-
writes.push({
|
|
133
|
-
path: `${levelsPath}/${targetLevelId}`,
|
|
134
|
-
arrayUnion: { field: "children", values: roots.map(entryOf) },
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
writes.push({
|
|
139
|
-
path: `${levelsPath}/${targetLevelId}`,
|
|
140
|
-
fields: { children: roots.map(entryOf) },
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return writes;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* [levelId]のlevelsドキュメントのchildren配列から、idが[removeIds]に含まれる
|
|
148
|
-
* エントリを取り除くFirestoreWriteを1件返す。ドキュメントが無ければ何もしない
|
|
149
|
-
* (levelsはbest-effortの書き込みで、bodiesと食い違っていても実害は無いため)。
|
|
150
|
-
* requireUpdateTimeで楽観的ロックする(呼び出し側はrunOptimisticで使うこと)。
|
|
151
|
-
*/
|
|
152
|
-
export async function buildRemoveChildrenWrite(client, levelsPath, levelId, removeIds) {
|
|
153
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
154
|
-
if (!doc)
|
|
155
|
-
return null;
|
|
156
|
-
const children = childrenOf(doc.data).filter((c) => !removeIds.includes(c.id));
|
|
157
|
-
return { path: `${levelsPath}/${levelId}`, fields: { children }, requireUpdateTime: doc.updateTime };
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* [levelId]のlevelsドキュメントのchildren配列の[afterId]の直後(nullなら先頭)に
|
|
161
|
-
* [entry]を挿入するFirestoreWriteを1件返す。ドキュメントが無ければ新規作成する
|
|
162
|
-
* (この場合はrequireUpdateTimeを付けない = 何も無い前提で作成する)。[afterId]が
|
|
163
|
-
* 見つからない場合は末尾に足す(Dart版LevelsRepository.insertChildと同じ方針)。
|
|
164
|
-
*/
|
|
165
|
-
/**
|
|
166
|
-
* [levelId]のchildrenの中で[entry]を一度取り除いてから、[afterId]の直後
|
|
167
|
-
* (nullなら先頭)へ入れ直す書き込みを作る。同じ親の中での並べ替え専用。
|
|
168
|
-
*
|
|
169
|
-
* **remove用とinsert用の書き込みを別々に作って両方commitしてはいけない。**
|
|
170
|
-
* どちらも「元のchildren」を読んでから組み立てるため、同じドキュメントへの
|
|
171
|
-
* 書き込みが2つできて後勝ちになり、要素が重複する。
|
|
172
|
-
*/
|
|
173
|
-
export async function buildReorderChildWrite(client, levelsPath, levelId, entry, afterId) {
|
|
174
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
175
|
-
const children = (doc ? childrenOf(doc.data) : []).filter((c) => c.id !== entry.id);
|
|
176
|
-
const insertIndex = afterId === null
|
|
177
|
-
? 0
|
|
178
|
-
: (() => {
|
|
179
|
-
const found = children.findIndex((c) => c.id === afterId);
|
|
180
|
-
return found === -1 ? children.length : found + 1;
|
|
181
|
-
})();
|
|
182
|
-
children.splice(Math.min(Math.max(insertIndex, 0), children.length), 0, entry);
|
|
183
|
-
return {
|
|
184
|
-
path: `${levelsPath}/${levelId}`,
|
|
185
|
-
fields: { children },
|
|
186
|
-
requireUpdateTime: doc?.updateTime,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
export async function buildInsertChildWrite(client, levelsPath, levelId, entry, afterId) {
|
|
190
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
191
|
-
const children = doc ? childrenOf(doc.data) : [];
|
|
192
|
-
const insertIndex = afterId === null
|
|
193
|
-
? 0
|
|
194
|
-
: (() => {
|
|
195
|
-
const found = children.findIndex((c) => c.id === afterId);
|
|
196
|
-
return found === -1 ? children.length : found + 1;
|
|
197
|
-
})();
|
|
198
|
-
children.splice(Math.min(Math.max(insertIndex, 0), children.length), 0, entry);
|
|
199
|
-
return {
|
|
200
|
-
path: `${levelsPath}/${levelId}`,
|
|
201
|
-
fields: { children },
|
|
202
|
-
requireUpdateTime: doc?.updateTime,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* update_task_status用。[updates]を親ごとにグループ化し、levelsドキュメントを
|
|
207
|
-
* 親ごとに1回だけ読んで該当エントリを書き換えた配列を書き戻すFirestoreWrite[]
|
|
208
|
-
* を組み立てる(Dart版LevelsRepository.updateChildと同じ「読んで置換」方式)。
|
|
209
|
-
* 該当ドキュメントや該当エントリが見つからない場合は、その分だけ静かに
|
|
210
|
-
* スキップする(levelsはbest-effortの書き込みで、失敗しても実害は無いため)。
|
|
211
|
-
*/
|
|
212
|
-
export async function buildUpdateEntryWrites(client, levelsPath, updates) {
|
|
213
|
-
const byLevelId = new Map();
|
|
214
|
-
for (const update of updates) {
|
|
215
|
-
const levelId = update.parentId ?? "root";
|
|
216
|
-
const list = byLevelId.get(levelId) ?? [];
|
|
217
|
-
list.push({ elementId: update.elementId, patch: update.patch });
|
|
218
|
-
byLevelId.set(levelId, list);
|
|
219
|
-
}
|
|
220
|
-
const writes = [];
|
|
221
|
-
for (const [levelId, group] of byLevelId) {
|
|
222
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
223
|
-
if (!doc)
|
|
224
|
-
continue;
|
|
225
|
-
const children = childrenOf(doc.data);
|
|
226
|
-
for (const { elementId, patch } of group) {
|
|
227
|
-
const index = children.findIndex((c) => c.id === elementId);
|
|
228
|
-
if (index === -1)
|
|
229
|
-
continue;
|
|
230
|
-
children[index] = { ...children[index], ...patch };
|
|
231
|
-
}
|
|
232
|
-
// requireUpdateTime: 読み取り後に他の操作がこのlevelsドキュメントを
|
|
233
|
-
// 書き換えていた場合、この書き込みはFAILED_PRECONDITIONで失敗する
|
|
234
|
-
// (呼び出し側はrunOptimisticで読み取りからやり直すこと)。
|
|
235
|
-
writes.push({
|
|
236
|
-
path: `${levelsPath}/${levelId}`,
|
|
237
|
-
fields: { children },
|
|
238
|
-
requireUpdateTime: doc.updateTime,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
return writes;
|
|
242
|
-
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// levels
|
|
2
|
-
// bodiesを一切読まず書かず、levelsコレクションだけで要素を探して書き換える。
|
|
1
|
+
// levelsコレクションの読み取り結果から、要素を探して書き換える書き込みを組み立てる。
|
|
3
2
|
//
|
|
4
3
|
// どの操作も「levels全件の読み取り結果(LevelsSnapshot)」を受け取る純粋関数で、
|
|
5
4
|
// 触った子一覧を作業用に複製して編集し、最後に元の状態との差分から
|
|
@@ -12,6 +11,15 @@ import { childrenOf } from "./levels.js";
|
|
|
12
11
|
export function levelsSnapshotFromDocs(docs) {
|
|
13
12
|
return new Map(docs.map((d) => [d.id, { children: childrenOf(d.data), updateTime: d.updateTime }]));
|
|
14
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* levels全件を読む。levels/rootが無いマップは空のマップとして扱う
|
|
16
|
+
* (rootへの最初の書き込みは、存在しないことを前提条件にした新規作成になる)。
|
|
17
|
+
* アプリで開いてもrootは作られないので、無いときに止めると中身の無い古い
|
|
18
|
+
* マップへ永久に書き込めなくなる。
|
|
19
|
+
*/
|
|
20
|
+
export async function readLevelsSnapshot(client, levelsPath) {
|
|
21
|
+
return levelsSnapshotFromDocs(await client.listDocuments(levelsPath));
|
|
22
|
+
}
|
|
15
23
|
/**
|
|
16
24
|
* rootから辿れる要素だけを対象に、要素id -> 場所 の索引を作る。
|
|
17
25
|
* どこからも辿れない子一覧ドキュメント(過去の不整合の残骸)の中身は数えない。
|
|
@@ -30,10 +30,7 @@ export async function resolveMap(client, session, mapId) {
|
|
|
30
30
|
}
|
|
31
31
|
throw new MapNotFoundError(mapId);
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
return `users/${map.ownerUid}/headers/${map.headerId}/bodies`;
|
|
35
|
-
}
|
|
36
|
-
/** levels移行: bodiesPathOfと対になる、階層ごとの子一覧を配列で持つ新形式コレクション。 */
|
|
33
|
+
/** マップの中身(階層ごとの子一覧を配列で持つコレクション)のパス。 */
|
|
37
34
|
export function levelsPathOf(map) {
|
|
38
35
|
return `users/${map.ownerUid}/headers/${map.headerId}/levels`;
|
|
39
36
|
}
|
|
@@ -93,30 +93,29 @@ export function parseMarkdownForImport(markdownText, { headingsAsNodes }) {
|
|
|
93
93
|
return { nodes, headingTitle, hasCheckbox };
|
|
94
94
|
}
|
|
95
95
|
function buildRelations(nodes) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
96
|
+
// 親は「自分より字下げが浅い直近の要素」とする。字下げの幅(2・4・タブ)は
|
|
97
|
+
// 浅い深いの比較にしか使わないので、刻み幅が混ざっても同じ結果になる。
|
|
98
|
+
// 段を飛ばした字下げ(`#`の直下の`-`、字下げのばらつき)でも必ず
|
|
99
|
+
// どこかへ繋がり、親も前の兄弟も無い要素ができない。
|
|
100
|
+
// 祖先の並び。末尾が直前の要素で、字下げは末尾ほど深い。
|
|
101
|
+
const ancestors = [];
|
|
102
|
+
// 親のID(最上位はnull)ごとの、いちばん後ろの子。
|
|
103
|
+
const lastChildOf = new Map();
|
|
103
104
|
for (const node of nodes) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
node.parentId = lastNodeAtDepth.get(depth - 1) ?? null;
|
|
105
|
+
while (ancestors.length > 0 && ancestors[ancestors.length - 1].rawIndent >= node.rawIndent) {
|
|
106
|
+
ancestors.pop();
|
|
107
107
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
const parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
|
|
109
|
+
node.parentId = parent?.id ?? null;
|
|
110
|
+
const prev = lastChildOf.get(node.parentId);
|
|
111
|
+
if (prev !== undefined) {
|
|
112
|
+
node.prevId = prev.id;
|
|
113
|
+
prev.nextId = node.id;
|
|
112
114
|
}
|
|
113
|
-
else if (
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
lastNodeAtDepth.set(depth, node.id);
|
|
117
|
-
for (const key of [...lastNodeAtDepth.keys()]) {
|
|
118
|
-
if (key > depth)
|
|
119
|
-
lastNodeAtDepth.delete(key);
|
|
115
|
+
else if (parent !== null) {
|
|
116
|
+
parent.childId = node.id;
|
|
120
117
|
}
|
|
118
|
+
lastChildOf.set(node.parentId, node);
|
|
119
|
+
ancestors.push(node);
|
|
121
120
|
}
|
|
122
121
|
}
|
|
@@ -29,45 +29,6 @@ async function parseJsonResponse(res) {
|
|
|
29
29
|
}
|
|
30
30
|
/** Firestoreの1コミット(:commit)に含められる書き込み件数の上限(500)より余裕を持たせた値。 */
|
|
31
31
|
const COMMIT_CHUNK_SIZE = 400;
|
|
32
|
-
/**
|
|
33
|
-
* 同じドキュメントを指す書き込みを1件にまとめる。
|
|
34
|
-
*
|
|
35
|
-
* **1回のcommitに同じpathの書き込みを2つ以上積んではいけない。** Firestoreは
|
|
36
|
-
* エラーにせず順に適用するため、後の書き込みが前の書き込みを打ち消す。
|
|
37
|
-
* 実データで2度踏んだ(move_elementで要素が重複、delete_elementsで存在しない
|
|
38
|
-
* IDを指すポインタが残る)。writesを返す直前に必ずこれを通すこと。
|
|
39
|
-
*
|
|
40
|
-
* deleteが1つでもあればdeleteが勝つ(消すドキュメントへの部分更新は無意味)。
|
|
41
|
-
* fields同士はマージし、requireUpdateTimeは最初に読み取った値を残す。
|
|
42
|
-
*/
|
|
43
|
-
export function mergeWritesByPath(writes) {
|
|
44
|
-
const byPath = new Map();
|
|
45
|
-
const order = [];
|
|
46
|
-
for (const write of writes) {
|
|
47
|
-
const prev = byPath.get(write.path);
|
|
48
|
-
if (!prev) {
|
|
49
|
-
byPath.set(write.path, { ...write });
|
|
50
|
-
order.push(write.path);
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (write.delete || prev.delete) {
|
|
54
|
-
byPath.set(write.path, { path: write.path, delete: true });
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (write.arrayUnion || prev.arrayUnion) {
|
|
58
|
-
// 配列追記と通常の更新は混ぜられない。後から積んだ方を採る。
|
|
59
|
-
byPath.set(write.path, { ...write });
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
byPath.set(write.path, {
|
|
63
|
-
path: write.path,
|
|
64
|
-
fields: { ...(prev.fields ?? {}), ...(write.fields ?? {}) },
|
|
65
|
-
requireUpdateTime: prev.requireUpdateTime ?? write.requireUpdateTime,
|
|
66
|
-
requireMissing: prev.requireMissing ?? write.requireMissing,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return order.map((path) => byPath.get(path));
|
|
70
|
-
}
|
|
71
32
|
export class FirestoreRestClient {
|
|
72
33
|
session;
|
|
73
34
|
constructor(session) {
|
package/dist/src/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { registerDeleteElements } from "./tools/deleteElements.js";
|
|
|
12
12
|
import { registerMoveElement } from "./tools/moveElement.js";
|
|
13
13
|
const server = new McpServer({
|
|
14
14
|
name: "matryoshka-mindmap",
|
|
15
|
-
version: "0.1.
|
|
15
|
+
version: "0.1.3",
|
|
16
16
|
}, {
|
|
17
17
|
instructions: "このサーバーはリアルタイム同期を行わない。各ツールは呼び出された瞬間の" +
|
|
18
18
|
"Firestoreの状態を返すだけで、アプリ側でユーザーが直接行った変更は" +
|