mmm-mcp-server 0.1.2 → 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.
@@ -1,11 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
- import { isLevelsOnly, readLevelsSnapshot } from "../domain/levelsMode.js";
4
- import { ancestorDetailsInLevels, buildLevelsDeleteWrites, locateInLevels, subtreeIdsInLevels } from "../domain/levelsOps.js";
5
- import { resolveMap, bodiesPathOf, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
6
- import { bodyFromFirestore, ancestorDetails, collectSubtreeIds } from "../domain/body.js";
7
- import { assertLevelsRootExists, levelsDocExists } from "../domain/levels.js";
8
- import { buildDeleteElementWrites } from "../domain/deleteElements.js";
3
+ import { ancestorDetailsInLevels, buildLevelsDeleteWrites, locateInLevels, subtreeIdsInLevels, readLevelsSnapshot, withLinkSummary, } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, headerPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
9
5
  export function registerDeleteElements(server) {
10
6
  server.tool("delete_elements", "既存マップから要素を削除する。指定した要素とその配下(子孫)を丸ごと削除する。" +
11
7
  "削除は取り返しがつかないため、まずconfirmedを省略(またはfalse)で呼び、" +
@@ -22,88 +18,20 @@ export function registerDeleteElements(server) {
22
18
  const map = await resolveMap(client, session, mapId);
23
19
  assertCanEditMap(map, session);
24
20
  const levelsPath = levelsPathOf(map);
25
- const bodiesPath = bodiesPathOf(map);
26
- // 切替後はrootが無いマップを空のマップとして扱うので、この確認は切替前だけ。
27
- const levelsOnly = await isLevelsOnly(client);
28
- if (!levelsOnly) {
29
- assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
30
- }
31
- if (levelsOnly) {
32
- if (confirmed !== true) {
33
- const snapshot = await readLevelsSnapshot(client, levelsPath);
34
- const allTargets = new Set();
35
- const targets = [];
36
- for (const elementId of elementIds) {
37
- if (!locateInLevels(snapshot, elementId)) {
38
- throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
39
- }
40
- const subtree = subtreeIdsInLevels(snapshot, elementId);
41
- for (const id of subtree)
42
- allTargets.add(id);
43
- targets.push({
44
- elementId,
45
- path: formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId)),
46
- subtreeCount: subtree.length,
47
- });
48
- }
49
- return {
50
- content: [
51
- {
52
- type: "text",
53
- text: JSON.stringify({
54
- confirmationRequired: true,
55
- message: "削除は取り返しがつきません。以下の内容をユーザーに提示して明示的な同意を得てから、" +
56
- "confirmed: trueで呼び直してください。",
57
- targets,
58
- totalElementCount: allTargets.size,
59
- }, null, 2),
60
- },
61
- ],
62
- };
63
- }
64
- const levelsDeleted = [];
65
- let levelsTotal = 0;
66
- await client.runOptimistic(async () => {
67
- levelsDeleted.length = 0;
68
- const snapshot = await readLevelsSnapshot(client, levelsPath);
69
- for (const elementId of elementIds) {
70
- if (!locateInLevels(snapshot, elementId))
71
- continue; // 既に削除済み(リトライ時など)
72
- levelsDeleted.push({
73
- elementId,
74
- path: formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId)),
75
- subtreeCount: subtreeIdsInLevels(snapshot, elementId).length,
76
- });
77
- }
78
- const { writes, deletedIds } = buildLevelsDeleteWrites(levelsPath, snapshot, elementIds);
79
- levelsTotal = deletedIds.size;
80
- return writes;
81
- });
82
- return {
83
- content: [
84
- {
85
- type: "text",
86
- text: JSON.stringify({ deleted: levelsDeleted, totalElementCount: levelsTotal }, null, 2),
87
- },
88
- ],
89
- };
90
- }
91
21
  if (confirmed !== true) {
92
- const existingDocs = await client.listDocuments(bodiesPath);
93
- const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
94
- const byId = new Map(existingBodies.map((b) => [b.id, b]));
22
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
95
23
  const allTargets = new Set();
96
24
  const targets = [];
97
25
  for (const elementId of elementIds) {
98
- if (!byId.has(elementId)) {
26
+ if (!locateInLevels(snapshot, elementId)) {
99
27
  throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
100
28
  }
101
- const subtree = collectSubtreeIds(existingBodies, elementId);
29
+ const subtree = subtreeIdsInLevels(snapshot, elementId);
102
30
  for (const id of subtree)
103
31
  allTargets.add(id);
104
32
  targets.push({
105
33
  elementId,
106
- path: formatPath(map.header.title, ancestorDetails(existingBodies, elementId)),
34
+ path: formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId)),
107
35
  subtreeCount: subtree.length,
108
36
  });
109
37
  }
@@ -122,31 +50,30 @@ export function registerDeleteElements(server) {
122
50
  ],
123
51
  };
124
52
  }
125
- const deletedPaths = [];
126
- let totalDeleted = 0;
53
+ const levelsDeleted = [];
54
+ let levelsTotal = 0;
127
55
  await client.runOptimistic(async () => {
128
- deletedPaths.length = 0;
129
- totalDeleted = 0;
130
- const currentDocs = await client.listDocuments(bodiesPath);
131
- const currentBodies = currentDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
56
+ levelsDeleted.length = 0;
57
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
132
58
  for (const elementId of elementIds) {
133
- if (!currentBodies.some((b) => b.id === elementId))
59
+ if (!locateInLevels(snapshot, elementId))
134
60
  continue; // 既に削除済み(リトライ時など)
135
- deletedPaths.push({
61
+ levelsDeleted.push({
136
62
  elementId,
137
- path: formatPath(map.header.title, ancestorDetails(currentBodies, elementId)),
138
- subtreeCount: collectSubtreeIds(currentBodies, elementId).length,
63
+ path: formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId)),
64
+ subtreeCount: subtreeIdsInLevels(snapshot, elementId).length,
139
65
  });
140
66
  }
141
- const { writes, deletedIds } = await buildDeleteElementWrites(client, bodiesPath, levelsPath, currentBodies, elementIds);
142
- totalDeleted = deletedIds.size;
143
- return writes;
67
+ const { writes, deletedIds } = buildLevelsDeleteWrites(levelsPath, snapshot, elementIds);
68
+ levelsTotal = deletedIds.size;
69
+ // リンクの要約(header の linkCounts)が変わるときだけ、同じコミットで書き直す
70
+ return withLinkSummary(headerPathOf(map), levelsPath, snapshot, writes);
144
71
  });
145
72
  return {
146
73
  content: [
147
74
  {
148
75
  type: "text",
149
- text: JSON.stringify({ deleted: deletedPaths, totalElementCount: totalDeleted }, null, 2),
76
+ text: JSON.stringify({ deleted: levelsDeleted, totalElementCount: levelsTotal }, null, 2),
150
77
  },
151
78
  ],
152
79
  };
@@ -1,65 +1,37 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
- import { isLevelsOnly, readLevelsSnapshot } from "../domain/levelsMode.js";
4
- import { ancestorDetailsInLevels, buildLevelsPatchWrites } from "../domain/levelsOps.js";
5
- import { resolveMap, bodiesPathOf, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
6
- import { bodyFromFirestore, ancestorDetails } from "../domain/body.js";
7
- import { mergeWritesByPath } from "../firestore/restClient.js";
8
- import { assertLevelsRootExists, buildUpdateEntryWrites, levelsDocExists } from "../domain/levels.js";
3
+ import { assertDetailLengths } from "../domain/detailLimit.js";
4
+ import { ancestorDetailsInLevels, buildLevelsPatchWrites, readLevelsSnapshot } from "../domain/levelsOps.js";
5
+ import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
9
6
  export function registerEditElement(server) {
10
7
  server.tool("edit_element", "既存マップの要素1件の本文(detail)を書き換える。要素の追加はauto_structure_thought・" +
11
8
  "import_markdown_contextを、チェック状態の変更はupdate_task_statusを使うこと。", {
12
9
  mapId: z.string().describe("list_mapsで得たマップのID"),
13
10
  elementId: z.string().describe("list_elementsで得た要素のID"),
14
- detail: z.string().min(1).describe("書き換え後の本文"),
11
+ detail: z.string().min(1).describe("書き換え後の本文(1要素5000文字まで)"),
15
12
  }, async ({ mapId, elementId, detail }) => {
13
+ assertDetailLengths([detail]);
16
14
  const { client, session } = getContext();
17
15
  const map = await resolveMap(client, session, mapId);
18
16
  assertCanEditMap(map, session);
19
17
  const levelsPath = levelsPathOf(map);
20
- const bodiesPath = bodiesPathOf(map);
21
- if (await isLevelsOnly(client)) {
22
- let levelsPathResult = null;
23
- await client.runOptimistic(async () => {
24
- const snapshot = await readLevelsSnapshot(client, levelsPath);
25
- const { writes, found } = buildLevelsPatchWrites(levelsPath, snapshot, [
26
- { elementId, patch: { detail } },
27
- ]);
28
- if (!found.has(elementId)) {
29
- throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
30
- }
31
- levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId));
32
- return writes;
33
- });
34
- return {
35
- content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
36
- };
37
- }
38
- assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
39
- let path = null;
18
+ let levelsPathResult = null;
40
19
  await client.runOptimistic(async () => {
41
- const bodyDoc = await client.getDocument(`${bodiesPath}/${elementId}`);
42
- if (!bodyDoc) {
20
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
21
+ const { writes, found } = buildLevelsPatchWrites(levelsPath, snapshot, [
22
+ { elementId, patch: { detail } },
23
+ ]);
24
+ if (!found.has(elementId)) {
43
25
  throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
44
26
  }
45
- const body = bodyFromFirestore(elementId, bodyDoc.data, bodyDoc.updateTime);
46
- const existingDocs = await client.listDocuments(bodiesPath);
47
- const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
48
- path = formatPath(map.header.title, ancestorDetails(existingBodies, elementId));
49
- const writes = [
50
- {
51
- path: `${bodiesPath}/${elementId}`,
52
- fields: { detail },
53
- requireUpdateTime: body.updateTime,
54
- },
55
- ];
56
- const levelWrites = await buildUpdateEntryWrites(client, levelsPath, [
57
- { parentId: body.parent, elementId, patch: { detail } },
58
- ]);
59
- return mergeWritesByPath([...writes, ...levelWrites]);
27
+ // snapshotは書き込み前なので、末尾(この要素自身)だけ書き換え後の本文にする。
28
+ const chain = ancestorDetailsInLevels(snapshot, elementId);
29
+ chain[chain.length - 1] = detail;
30
+ levelsPathResult = formatPath(map.header.title, chain);
31
+ return writes;
60
32
  });
61
33
  return {
62
- content: [{ type: "text", text: JSON.stringify({ path }, null, 2) }],
34
+ content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
63
35
  };
64
36
  });
65
37
  }
@@ -1,13 +1,9 @@
1
1
  import { z } from "zod";
2
- import { mergeWritesByPath } from "../firestore/restClient.js";
3
2
  import { getContext } from "../mcpContext.js";
4
- import { isLevelsOnly, readLevelsSnapshot } from "../domain/levelsMode.js";
5
- import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels } from "../domain/levelsOps.js";
6
- import { resolveMap, bodiesPathOf, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
7
- import { bodyFromFirestore, ancestorDetails } from "../domain/body.js";
8
- import { buildAppendWrites } from "../domain/appendForest.js";
3
+ import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
9
5
  import { parseMarkdownForImport } from "../domain/markdownParser.js";
10
- import { assertLevelsRootExists, buildAppendLevelWrites, levelsDocExists } from "../domain/levels.js";
6
+ import { assertDetailLengths } from "../domain/detailLimit.js";
11
7
  export function registerImportMarkdownContext(server) {
12
8
  server.tool("import_markdown_context", "任意の深さのMarkdown(インデントで階層を表現したリスト、見出しは`#`)を組み立てて、" +
13
9
  "既存のマップに流し込む。見出しも1つの要素として取り込む。" +
@@ -15,7 +11,7 @@ export function registerImportMarkdownContext(server) {
15
11
  "チェック状態も反映されない(アプリ本体の挙動と同じ)。" +
16
12
  "対象は既存マップのみ(新規作成が必要ならcreate_mapを先に呼ぶ)。", {
17
13
  mapId: z.string().describe("list_mapsで得たマップのID"),
18
- 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の現地時刻)"),
19
15
  parentElementId: z
20
16
  .string()
21
17
  .nullable()
@@ -26,66 +22,28 @@ export function registerImportMarkdownContext(server) {
26
22
  const map = await resolveMap(client, session, mapId);
27
23
  assertCanEditMap(map, session);
28
24
  const levelsPath = levelsPathOf(map);
29
- const bodiesPath = bodiesPathOf(map);
30
25
  const targetParentId = parentElementId ?? null;
31
26
  const parsed = parseMarkdownForImport(markdown, { headingsAsNodes: true });
32
27
  if (parsed.nodes.length === 0) {
33
28
  throw new Error("Markdownからリスト項目・見出しが見つかりませんでした。");
34
29
  }
35
- if (await isLevelsOnly(client)) {
36
- let levelsPathResult = "";
37
- await client.runOptimistic(async () => {
38
- const snapshot = await readLevelsSnapshot(client, levelsPath);
39
- if (targetParentId !== null && !locateInLevels(snapshot, targetParentId)) {
40
- throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
41
- }
42
- levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, targetParentId));
43
- return buildLevelsAppendWrites(levelsPath, snapshot, parsed.nodes, targetParentId, map.header.isTodo);
44
- });
45
- return {
46
- content: [
47
- {
48
- type: "text",
49
- text: JSON.stringify({
50
- path: levelsPathResult,
51
- addedElementCount: parsed.nodes.length,
52
- checkboxIgnored: !map.header.isTodo && parsed.hasCheckbox,
53
- }, null, 2),
54
- },
55
- ],
56
- };
57
- }
58
- // 読み取り(bodies一覧・levels存在確認)から書き込みまでを楽観的
59
- // ロックでまとめる。読み取り後に対象ドキュメントが他から変更される
60
- // とコミットがFAILED_PRECONDITIONになり、読み取りからやり直す。
61
- let createdIds = [];
62
- let existingBodiesForPath = [];
30
+ assertDetailLengths(parsed.nodes.map((node) => node.detail));
31
+ let levelsPathResult = "";
63
32
  await client.runOptimistic(async () => {
64
- assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
65
- const existingDocs = await client.listDocuments(bodiesPath);
66
- const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
67
- existingBodiesForPath = existingBodies;
68
- if (targetParentId !== null && !existingBodies.some((b) => b.id === targetParentId)) {
33
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
34
+ if (targetParentId !== null && !locateInLevels(snapshot, targetParentId)) {
69
35
  throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
70
36
  }
71
- const { writes, createdIds: ids } = buildAppendWrites(bodiesPath, existingBodies, parsed.nodes, targetParentId, map.header.isTodo);
72
- createdIds = ids;
73
- // 挿入先のlevelsドキュメントが実際に存在するかを直接確認する
74
- // (bodies側の状態から推測すると、levelsとbodiesが食い違っている
75
- // 場合にarrayUnion書き込みが「ドキュメントが無い」で失敗しうるため)。
76
- const targetLevelId = targetParentId ?? "root";
77
- const targetHasLevelsDoc = targetLevelId === "root" || (await levelsDocExists(client, levelsPath, targetLevelId));
78
- const levelWrites = buildAppendLevelWrites(levelsPath, parsed.nodes, targetParentId, targetHasLevelsDoc, map.header.isTodo);
79
- return mergeWritesByPath([...writes, ...levelWrites]);
37
+ levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, targetParentId));
38
+ return buildLevelsAppendWrites(levelsPath, snapshot, parsed.nodes, targetParentId, map.header.isTodo);
80
39
  });
81
- const path = formatPath(map.header.title, ancestorDetails(existingBodiesForPath, targetParentId));
82
40
  return {
83
41
  content: [
84
42
  {
85
43
  type: "text",
86
44
  text: JSON.stringify({
87
- path,
88
- addedElementCount: createdIds.length,
45
+ path: levelsPathResult,
46
+ addedElementCount: parsed.nodes.length,
89
47
  checkboxIgnored: !map.header.isTodo && parsed.hasCheckbox,
90
48
  }, null, 2),
91
49
  },
@@ -1,14 +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 { isLevelsOnly } from "../domain/levelsMode.js";
5
- import { assertLevelsInitialized, buildTreeFromLevels, readAllLevelDocs, } from "../domain/levels.js";
4
+ import { elementTreeOf, readLevelsSnapshot } from "../domain/levelsOps.js";
5
+ import { formatScheduleDate } from "../domain/scheduleText.js";
6
6
  // ElementNodeの`done`を`checked`という名前で公開する(仕様書の用語に合わせる)。
7
7
  function toChecked(nodes) {
8
8
  return nodes.map((n) => ({
9
9
  id: n.id,
10
10
  detail: n.detail,
11
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) } : {}),
12
15
  children: toChecked(n.children),
13
16
  }));
14
17
  }
@@ -38,21 +41,24 @@ class AiReadDeniedError extends Error {
38
41
  }
39
42
  }
40
43
  export function registerListElements(server) {
41
- 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の現地時刻)。" +
42
47
  "checkedは各要素にFirestoreへ実際に保存されている生の値であり、" +
43
48
  "「祖先がチェック済みなら配下も完了とみなす」という加工は行っていない。" +
44
49
  "そう解釈したい場合は、返ってきたツリーを見てこちら(呼び出す側)で判断すること。" +
45
- "マップの中身には秘匿情報が含まれ得るため、初めて読むマップでは呼び出し前に" +
46
- "ユーザー本人の許可が必要。confirmed: trueを付けずに呼ぶと、このツールは" +
47
- "実データを返す代わりに確認を促すメッセージだけを返すので、その指示に従って" +
48
- "ユーザーに直接確認してからconfirmed: trueで呼び直すこと。" +
50
+ "呼ぶ前にユーザーへ確認しないこと。まずconfirmedを付けずに呼ぶ。" +
51
+ "アプリでAIからの読み取りを「常に許可」にしたマップは、そのまま実データを返す。" +
52
+ "それ以外のマップは、実データの代わりに確認を促すメッセージだけを返すので、" +
53
+ "そのときに限りユーザーに直接確認してからconfirmed: trueで呼び直すこと。" +
49
54
  "アプリ側での直接の編集はこのツールを呼ぶまで反映されないため、" +
50
55
  "ユーザーから進捗や状態を尋ねられたときは記憶に頼らず必ず呼び直すこと。", {
51
56
  mapId: z.string().describe("list_mapsで得たマップのID"),
52
57
  confirmed: z
53
58
  .boolean()
54
59
  .optional()
55
- .describe("ユーザー本人に直接「中身を確認してよいか」と尋ね、同意を得てからtrueを付けて呼ぶこと。" +
60
+ .describe("最初は付けない。確認を促すメッセージが返ったときだけ、ユーザー本人に直接" +
61
+ "「中身を確認してよいか」と尋ね、同意を得てからtrueを付けて呼び直す。" +
56
62
  "確認前にtrueを付けてはならない。"),
57
63
  }, async ({ mapId, confirmed }) => {
58
64
  const { client, session } = getContext();
@@ -78,13 +84,8 @@ export function registerListElements(server) {
78
84
  return { content: [{ type: "text", text: buildConfirmationRequestText(map.header.title) }] };
79
85
  }
80
86
  }
81
- const levelsById = await readAllLevelDocs(client, levelsPathOf(map));
82
- // 切替前はrootが無ければ未移行(bodiesに中身があり得る)なので止める。
83
- // 切替後はアプリで開いてもrootが作られないので、空のマップとして返す。
84
- if (!(await isLevelsOnly(client))) {
85
- assertLevelsInitialized(levelsById, map.header.title);
86
- }
87
- const tree = buildTreeFromLevels(levelsById);
87
+ // levels/rootが無いマップは空のマップとして返す(アプリで開いてもrootは作られない)。
88
+ const tree = elementTreeOf(await readLevelsSnapshot(client, levelsPathOf(map)));
88
89
  return {
89
90
  content: [
90
91
  {
@@ -1,11 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
- import { isLevelsOnly, readLevelsSnapshot } from "../domain/levelsMode.js";
4
- import { ancestorDetailsInLevels, buildLevelsMoveWrites, locateInLevels } from "../domain/levelsOps.js";
5
- import { resolveMap, bodiesPathOf, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
6
- import { bodyFromFirestore, ancestorDetails } from "../domain/body.js";
7
- import { assertLevelsRootExists, levelsDocExists } from "../domain/levels.js";
8
- import { buildMoveElementWrites } from "../domain/moveElement.js";
3
+ import { ancestorDetailsInLevels, buildLevelsMoveWrites, locateInLevels, readLevelsSnapshot, withLinkSummary, } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, headerPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
9
5
  export function registerMoveElement(server) {
10
6
  server.tool("move_element", "既存マップの要素1件(配下ごと)を、同じマップ内の別の場所へ移動する。" +
11
7
  "階層をまたいだ移動(昇格・降格)も、同じ階層内の並べ替えも、これ1つで行える。", {
@@ -24,62 +20,25 @@ export function registerMoveElement(server) {
24
20
  const map = await resolveMap(client, session, mapId);
25
21
  assertCanEditMap(map, session);
26
22
  const levelsPath = levelsPathOf(map);
27
- const bodiesPath = bodiesPathOf(map);
28
- // 切替後はrootが無いマップを空のマップとして扱うので、この確認は切替前だけ。
29
- const levelsOnly = await isLevelsOnly(client);
30
- if (!levelsOnly) {
31
- assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
32
- }
33
23
  if (elementId === newParentElementId) {
34
24
  throw new Error("要素を自分自身の子にすることはできません。");
35
25
  }
36
26
  if (elementId === afterElementId) {
37
27
  throw new Error("afterElementIdにelementId自身は指定できません。");
38
28
  }
39
- if (levelsOnly) {
40
- let levelsPathResult = null;
41
- await client.runOptimistic(async () => {
42
- const snapshot = await readLevelsSnapshot(client, levelsPath);
43
- if (afterElementId !== null && !locateInLevels(snapshot, afterElementId)) {
44
- throw new Error(`afterElementId(${afterElementId})がこのマップに見つかりません。`);
45
- }
46
- const writes = buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParentElementId, afterElementId);
47
- levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, newParentElementId));
48
- return writes;
49
- });
50
- return {
51
- content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
52
- };
53
- }
54
- let path = null;
29
+ let levelsPathResult = null;
55
30
  await client.runOptimistic(async () => {
56
- const existingDocs = await client.listDocuments(bodiesPath);
57
- const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
58
- const byId = new Map(existingBodies.map((b) => [b.id, b]));
59
- const body = byId.get(elementId);
60
- if (!body) {
61
- throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
62
- }
63
- if (newParentElementId !== null && !byId.has(newParentElementId)) {
64
- throw new Error(`newParentElementId(${newParentElementId})がこのマップに見つかりません。`);
65
- }
66
- if (afterElementId !== null && !byId.has(afterElementId)) {
31
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
32
+ if (afterElementId !== null && !locateInLevels(snapshot, afterElementId)) {
67
33
  throw new Error(`afterElementId(${afterElementId})がこのマップに見つかりません。`);
68
34
  }
69
- // 移動先が自分自身の配下(子孫)だと、木構造が壊れる(循環になる)。
70
- for (let cur = newParentElementId; cur !== null; cur = byId.get(cur)?.parent ?? null) {
71
- if (cur === elementId) {
72
- throw new Error("要素を自分自身の配下(子孫)の中へ移動することはできません。");
73
- }
74
- }
75
- const writes = await buildMoveElementWrites(client, bodiesPath, levelsPath, existingBodies, elementId, newParentElementId, afterElementId);
76
- path = formatPath(map.header.title, newParentElementId === null
77
- ? []
78
- : ancestorDetails(existingBodies, newParentElementId));
79
- return writes;
35
+ const writes = buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParentElementId, afterElementId);
36
+ levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, newParentElementId));
37
+ // リンクの要約(header の linkCounts)が変わるときだけ、同じコミットで書き直す
38
+ return withLinkSummary(headerPathOf(map), levelsPath, snapshot, writes);
80
39
  });
81
40
  return {
82
- content: [{ type: "text", text: JSON.stringify({ path }, null, 2) }],
41
+ content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
83
42
  };
84
43
  });
85
44
  }
@@ -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
+ }
@@ -1,11 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
- import { isLevelsOnly, readLevelsSnapshot } from "../domain/levelsMode.js";
4
- import { ancestorDetailsInLevels, buildLevelsPatchWrites } from "../domain/levelsOps.js";
5
- import { resolveMap, bodiesPathOf, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
6
- import { bodyFromFirestore, ancestorDetails } from "../domain/body.js";
7
- import { mergeWritesByPath } from "../firestore/restClient.js";
8
- import { assertLevelsRootExists, buildUpdateEntryWrites, levelsDocExists } from "../domain/levels.js";
3
+ import { ancestorDetailsInLevels, buildLevelsPatchWrites, readLevelsSnapshot } from "../domain/levelsOps.js";
4
+ import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
9
5
  export function registerUpdateTaskStatus(server) {
10
6
  server.tool("update_task_status", "TODOマップの要素のチェック状態を書き換える。1件でも複数件でもよい。" +
11
7
  "「親をチェックすると配下も完了扱いになる」というアプリ内の表示上の挙動は" +
@@ -26,62 +22,22 @@ export function registerUpdateTaskStatus(server) {
26
22
  throw new Error(`「${map.header.title}」はTODOマップではないため、チェック状態を持ちません。`);
27
23
  }
28
24
  const levelsPath = levelsPathOf(map);
29
- const bodiesPath = bodiesPathOf(map);
30
- if (await isLevelsOnly(client)) {
31
- let levelsResults = [];
32
- await client.runOptimistic(async () => {
33
- const snapshot = await readLevelsSnapshot(client, levelsPath);
34
- const { writes, found } = buildLevelsPatchWrites(levelsPath, snapshot, updates.map((u) => ({ elementId: u.elementId, patch: { done: u.checked } })));
35
- levelsResults = updates.map((u) => ({
36
- elementId: u.elementId,
37
- path: found.has(u.elementId)
38
- ? formatPath(map.header.title, ancestorDetailsInLevels(snapshot, u.elementId))
39
- : null,
40
- checked: u.checked,
41
- found: found.has(u.elementId),
42
- }));
43
- return writes;
44
- });
45
- return {
46
- content: [{ type: "text", text: JSON.stringify({ results: levelsResults }, null, 2) }],
47
- };
48
- }
49
- // 読み取り(bodies一覧・levelsの該当ドキュメント)から書き込みまでを
50
- // 楽観的ロックでまとめる。読み取り後に他の操作が同じlevelsドキュメントを
51
- // 書き換えていた場合はコミットがFAILED_PRECONDITIONになり、
52
- // 読み取りからやり直す(runOptimisticが自動でリトライする)。
53
- let results = [];
25
+ let levelsResults = [];
54
26
  await client.runOptimistic(async () => {
55
- assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
56
- const existingDocs = await client.listDocuments(bodiesPath);
57
- const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
58
- const byId = new Map(existingBodies.map((b) => [b.id, b]));
59
- const writes = [];
60
- const levelUpdates = [];
61
- results = [];
62
- for (const update of updates) {
63
- const body = byId.get(update.elementId);
64
- if (!body) {
65
- results.push({ elementId: update.elementId, path: null, checked: update.checked, found: false });
66
- continue;
67
- }
68
- writes.push({
69
- path: `${bodiesPath}/${update.elementId}`,
70
- fields: { done: update.checked },
71
- });
72
- levelUpdates.push({
73
- parentId: body.parent,
74
- elementId: update.elementId,
75
- patch: { done: update.checked },
76
- });
77
- const path = formatPath(map.header.title, ancestorDetails(existingBodies, update.elementId));
78
- results.push({ elementId: update.elementId, path, checked: update.checked, found: true });
79
- }
80
- const levelWrites = await buildUpdateEntryWrites(client, levelsPath, levelUpdates);
81
- return mergeWritesByPath([...writes, ...levelWrites]);
27
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
28
+ const { writes, found } = buildLevelsPatchWrites(levelsPath, snapshot, updates.map((u) => ({ elementId: u.elementId, patch: { done: u.checked } })));
29
+ levelsResults = updates.map((u) => ({
30
+ elementId: u.elementId,
31
+ path: found.has(u.elementId)
32
+ ? formatPath(map.header.title, ancestorDetailsInLevels(snapshot, u.elementId))
33
+ : null,
34
+ checked: u.checked,
35
+ found: found.has(u.elementId),
36
+ }));
37
+ return writes;
82
38
  });
83
39
  return {
84
- content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }],
40
+ content: [{ type: "text", text: JSON.stringify({ results: levelsResults }, null, 2) }],
85
41
  };
86
42
  });
87
43
  }