mmm-mcp-server 0.1.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/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/bin/login.js +119 -0
- package/dist/src/auth/appleClientSecretRemote.js +29 -0
- package/dist/src/auth/appleLoopback.js +75 -0
- package/dist/src/auth/appleTokenExchange.js +29 -0
- package/dist/src/auth/firebaseAuthRest.js +98 -0
- package/dist/src/auth/googleLoopback.js +93 -0
- package/dist/src/auth/googleOAuthConfig.js +26 -0
- package/dist/src/auth/session.js +58 -0
- package/dist/src/auth/tokenStore.js +32 -0
- package/dist/src/domain/appendForest.js +82 -0
- package/dist/src/domain/body.js +127 -0
- package/dist/src/domain/deleteElements.js +85 -0
- package/dist/src/domain/header.js +27 -0
- package/dist/src/domain/idGen.js +11 -0
- package/dist/src/domain/levels.js +242 -0
- package/dist/src/domain/levelsMode.js +24 -0
- package/dist/src/domain/levelsOps.js +199 -0
- package/dist/src/domain/mapResolver.js +52 -0
- package/dist/src/domain/markdownParser.js +122 -0
- package/dist/src/domain/moveElement.js +93 -0
- package/dist/src/firebaseConfig.js +36 -0
- package/dist/src/firestore/restClient.js +267 -0
- package/dist/src/firestore/valueCodec.js +65 -0
- package/dist/src/mcpContext.js +11 -0
- package/dist/src/server.js +33 -0
- package/dist/src/tools/autoStructureThought.js +87 -0
- package/dist/src/tools/createMap.js +39 -0
- package/dist/src/tools/deleteElements.js +150 -0
- package/dist/src/tools/editElement.js +65 -0
- package/dist/src/tools/importMarkdownContext.js +95 -0
- package/dist/src/tools/listElements.js +97 -0
- package/dist/src/tools/listMaps.js +47 -0
- package/dist/src/tools/moveElement.js +81 -0
- package/dist/src/tools/updateTaskStatus.js +87 -0
- package/package.json +48 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
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";
|
|
9
|
+
export function registerDeleteElements(server) {
|
|
10
|
+
server.tool("delete_elements", "既存マップから要素を削除する。指定した要素とその配下(子孫)を丸ごと削除する。" +
|
|
11
|
+
"削除は取り返しがつかないため、まずconfirmedを省略(またはfalse)で呼び、" +
|
|
12
|
+
"返ってくる削除対象の一覧(パスと配下件数)をユーザーに提示して明示的な同意を得てから、" +
|
|
13
|
+
"confirmed: trueで呼び直すこと。", {
|
|
14
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
15
|
+
elementIds: z.array(z.string()).min(1).describe("list_elementsで得た要素のID(複数可)"),
|
|
16
|
+
confirmed: z
|
|
17
|
+
.boolean()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("true以外(省略時false)は削除対象の確認情報だけを返し、実際には削除しない"),
|
|
20
|
+
}, async ({ mapId, elementIds, confirmed }) => {
|
|
21
|
+
const { client, session } = getContext();
|
|
22
|
+
const map = await resolveMap(client, session, mapId);
|
|
23
|
+
assertCanEditMap(map, session);
|
|
24
|
+
const levelsPath = levelsPathOf(map);
|
|
25
|
+
const bodiesPath = bodiesPathOf(map);
|
|
26
|
+
assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
|
|
27
|
+
if (await isLevelsOnly(client)) {
|
|
28
|
+
if (confirmed !== true) {
|
|
29
|
+
const snapshot = await readLevelsSnapshot(client, levelsPath, map.header.title);
|
|
30
|
+
const allTargets = new Set();
|
|
31
|
+
const targets = [];
|
|
32
|
+
for (const elementId of elementIds) {
|
|
33
|
+
if (!locateInLevels(snapshot, elementId)) {
|
|
34
|
+
throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
35
|
+
}
|
|
36
|
+
const subtree = subtreeIdsInLevels(snapshot, elementId);
|
|
37
|
+
for (const id of subtree)
|
|
38
|
+
allTargets.add(id);
|
|
39
|
+
targets.push({
|
|
40
|
+
elementId,
|
|
41
|
+
path: formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId)),
|
|
42
|
+
subtreeCount: subtree.length,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.stringify({
|
|
50
|
+
confirmationRequired: true,
|
|
51
|
+
message: "削除は取り返しがつきません。以下の内容をユーザーに提示して明示的な同意を得てから、" +
|
|
52
|
+
"confirmed: trueで呼び直してください。",
|
|
53
|
+
targets,
|
|
54
|
+
totalElementCount: allTargets.size,
|
|
55
|
+
}, null, 2),
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const levelsDeleted = [];
|
|
61
|
+
let levelsTotal = 0;
|
|
62
|
+
await client.runOptimistic(async () => {
|
|
63
|
+
levelsDeleted.length = 0;
|
|
64
|
+
const snapshot = await readLevelsSnapshot(client, levelsPath, map.header.title);
|
|
65
|
+
for (const elementId of elementIds) {
|
|
66
|
+
if (!locateInLevels(snapshot, elementId))
|
|
67
|
+
continue; // 既に削除済み(リトライ時など)
|
|
68
|
+
levelsDeleted.push({
|
|
69
|
+
elementId,
|
|
70
|
+
path: formatPath(map.header.title, ancestorDetailsInLevels(snapshot, elementId)),
|
|
71
|
+
subtreeCount: subtreeIdsInLevels(snapshot, elementId).length,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const { writes, deletedIds } = buildLevelsDeleteWrites(levelsPath, snapshot, elementIds);
|
|
75
|
+
levelsTotal = deletedIds.size;
|
|
76
|
+
return writes;
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: JSON.stringify({ deleted: levelsDeleted, totalElementCount: levelsTotal }, null, 2),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (confirmed !== true) {
|
|
88
|
+
const existingDocs = await client.listDocuments(bodiesPath);
|
|
89
|
+
const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
|
|
90
|
+
const byId = new Map(existingBodies.map((b) => [b.id, b]));
|
|
91
|
+
const allTargets = new Set();
|
|
92
|
+
const targets = [];
|
|
93
|
+
for (const elementId of elementIds) {
|
|
94
|
+
if (!byId.has(elementId)) {
|
|
95
|
+
throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
96
|
+
}
|
|
97
|
+
const subtree = collectSubtreeIds(existingBodies, elementId);
|
|
98
|
+
for (const id of subtree)
|
|
99
|
+
allTargets.add(id);
|
|
100
|
+
targets.push({
|
|
101
|
+
elementId,
|
|
102
|
+
path: formatPath(map.header.title, ancestorDetails(existingBodies, elementId)),
|
|
103
|
+
subtreeCount: subtree.length,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: JSON.stringify({
|
|
111
|
+
confirmationRequired: true,
|
|
112
|
+
message: "削除は取り返しがつきません。以下の内容をユーザーに提示して明示的な同意を得てから、" +
|
|
113
|
+
"confirmed: trueで呼び直してください。",
|
|
114
|
+
targets,
|
|
115
|
+
totalElementCount: allTargets.size,
|
|
116
|
+
}, null, 2),
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const deletedPaths = [];
|
|
122
|
+
let totalDeleted = 0;
|
|
123
|
+
await client.runOptimistic(async () => {
|
|
124
|
+
deletedPaths.length = 0;
|
|
125
|
+
totalDeleted = 0;
|
|
126
|
+
const currentDocs = await client.listDocuments(bodiesPath);
|
|
127
|
+
const currentBodies = currentDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
|
|
128
|
+
for (const elementId of elementIds) {
|
|
129
|
+
if (!currentBodies.some((b) => b.id === elementId))
|
|
130
|
+
continue; // 既に削除済み(リトライ時など)
|
|
131
|
+
deletedPaths.push({
|
|
132
|
+
elementId,
|
|
133
|
+
path: formatPath(map.header.title, ancestorDetails(currentBodies, elementId)),
|
|
134
|
+
subtreeCount: collectSubtreeIds(currentBodies, elementId).length,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const { writes, deletedIds } = await buildDeleteElementWrites(client, bodiesPath, levelsPath, currentBodies, elementIds);
|
|
138
|
+
totalDeleted = deletedIds.size;
|
|
139
|
+
return writes;
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: JSON.stringify({ deleted: deletedPaths, totalElementCount: totalDeleted }, null, 2),
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
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";
|
|
9
|
+
export function registerEditElement(server) {
|
|
10
|
+
server.tool("edit_element", "既存マップの要素1件の本文(detail)を書き換える。要素の追加はauto_structure_thought・" +
|
|
11
|
+
"import_markdown_contextを、チェック状態の変更はupdate_task_statusを使うこと。", {
|
|
12
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
13
|
+
elementId: z.string().describe("list_elementsで得た要素のID"),
|
|
14
|
+
detail: z.string().min(1).describe("書き換え後の本文"),
|
|
15
|
+
}, async ({ mapId, elementId, detail }) => {
|
|
16
|
+
const { client, session } = getContext();
|
|
17
|
+
const map = await resolveMap(client, session, mapId);
|
|
18
|
+
assertCanEditMap(map, session);
|
|
19
|
+
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, map.header.title);
|
|
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;
|
|
40
|
+
await client.runOptimistic(async () => {
|
|
41
|
+
const bodyDoc = await client.getDocument(`${bodiesPath}/${elementId}`);
|
|
42
|
+
if (!bodyDoc) {
|
|
43
|
+
throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
44
|
+
}
|
|
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]);
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: JSON.stringify({ path }, null, 2) }],
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { mergeWritesByPath } from "../firestore/restClient.js";
|
|
3
|
+
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";
|
|
9
|
+
import { parseMarkdownForImport } from "../domain/markdownParser.js";
|
|
10
|
+
import { assertLevelsRootExists, buildAppendLevelWrites, levelsDocExists } from "../domain/levels.js";
|
|
11
|
+
export function registerImportMarkdownContext(server) {
|
|
12
|
+
server.tool("import_markdown_context", "任意の深さのMarkdown(インデントで階層を表現したリスト、見出しは`#`)を組み立てて、" +
|
|
13
|
+
"既存のマップに流し込む。見出しも1つの要素として取り込む。" +
|
|
14
|
+
"取り込み先がTODOマップでない場合、`- [ ]`/`- [x]`のチェックボックス記法は取り除かれ" +
|
|
15
|
+
"チェック状態も反映されない(アプリ本体の挙動と同じ)。" +
|
|
16
|
+
"対象は既存マップのみ(新規作成が必要ならcreate_mapを先に呼ぶ)。", {
|
|
17
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
18
|
+
markdown: z.string().min(1).describe("取り込むMarkdownテキスト"),
|
|
19
|
+
parentElementId: z
|
|
20
|
+
.string()
|
|
21
|
+
.nullable()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("この要素の内側(子)として取り込む。マップの最上位に取り込む場合はnullまたは省略"),
|
|
24
|
+
}, async ({ mapId, markdown, parentElementId }) => {
|
|
25
|
+
const { client, session } = getContext();
|
|
26
|
+
const map = await resolveMap(client, session, mapId);
|
|
27
|
+
assertCanEditMap(map, session);
|
|
28
|
+
const levelsPath = levelsPathOf(map);
|
|
29
|
+
const bodiesPath = bodiesPathOf(map);
|
|
30
|
+
const targetParentId = parentElementId ?? null;
|
|
31
|
+
const parsed = parseMarkdownForImport(markdown, { headingsAsNodes: true });
|
|
32
|
+
if (parsed.nodes.length === 0) {
|
|
33
|
+
throw new Error("Markdownからリスト項目・見出しが見つかりませんでした。");
|
|
34
|
+
}
|
|
35
|
+
if (await isLevelsOnly(client)) {
|
|
36
|
+
let levelsPathResult = "";
|
|
37
|
+
await client.runOptimistic(async () => {
|
|
38
|
+
const snapshot = await readLevelsSnapshot(client, levelsPath, map.header.title);
|
|
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 = [];
|
|
63
|
+
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)) {
|
|
69
|
+
throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
70
|
+
}
|
|
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]);
|
|
80
|
+
});
|
|
81
|
+
const path = formatPath(map.header.title, ancestorDetails(existingBodiesForPath, targetParentId));
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: "text",
|
|
86
|
+
text: JSON.stringify({
|
|
87
|
+
path,
|
|
88
|
+
addedElementCount: createdIds.length,
|
|
89
|
+
checkboxIgnored: !map.header.isTodo && parsed.hasCheckbox,
|
|
90
|
+
}, null, 2),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getContext } from "../mcpContext.js";
|
|
3
|
+
import { resolveMap, levelsPathOf } from "../domain/mapResolver.js";
|
|
4
|
+
import { assertLevelsInitialized, buildTreeFromLevels, readAllLevelDocs, } from "../domain/levels.js";
|
|
5
|
+
// ElementNodeの`done`を`checked`という名前で公開する(仕様書の用語に合わせる)。
|
|
6
|
+
function toChecked(nodes) {
|
|
7
|
+
return nodes.map((n) => ({
|
|
8
|
+
id: n.id,
|
|
9
|
+
detail: n.detail,
|
|
10
|
+
checked: n.done,
|
|
11
|
+
children: toChecked(n.children),
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
// マップの中身には秘匿情報が含まれ得るため、実際の要素を返す前に必ず本人の
|
|
15
|
+
// 明示的な許可を得る。このサーバープロセスが生きている間(概ね1会話)、
|
|
16
|
+
// 同じマップへの確認は1回でよい。
|
|
17
|
+
//
|
|
18
|
+
// 当初はMCP標準のelicitInputで確認していたが、接続クライアントが対応を
|
|
19
|
+
// 広告していても実際にはダイアログを一切出さないまま失敗することがあり
|
|
20
|
+
// (2026-09-03、Claude Codeデスクトップアプリで確認)、本人が一度も確認画面を
|
|
21
|
+
// 見ないまま恒久的に読み取り不能になる不具合があった。ホストごとの
|
|
22
|
+
// elicitation対応状況に依存しない、呼び出し側(Claude等)への文面提示+
|
|
23
|
+
// confirmedパラメータの方式に一本化した。呼び出し側が実際にユーザーへ
|
|
24
|
+
// 確認したかをこちらで検証する手段は無いため、確実な強制ではなく
|
|
25
|
+
// ベストエフォートであることを理解した上で採用している。
|
|
26
|
+
const confirmedMapIds = new Set();
|
|
27
|
+
function buildConfirmationRequestText(title) {
|
|
28
|
+
return (`「${title}」の中身を読み取る前に、ユーザー本人に直接「中身を確認してよいか」と` +
|
|
29
|
+
"尋ねてください。まだ実データは返していません。同意が得られたら、confirmed: true を" +
|
|
30
|
+
"付けてこのツールを再度呼び出してください。");
|
|
31
|
+
}
|
|
32
|
+
class AiReadDeniedError extends Error {
|
|
33
|
+
constructor(title) {
|
|
34
|
+
super(`「${title}」はAIからの読み取りが「常に拒否」に設定されているため、このツールでは読めません。` +
|
|
35
|
+
"読む必要がある場合は、アプリのマップ編集画面で設定を変更するようユーザーに伝えてください。");
|
|
36
|
+
this.name = "AiReadDeniedError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function registerListElements(server) {
|
|
40
|
+
server.tool("list_elements", "指定したマップ内の全要素と親子関係、チェック状態(checked)を返す。" +
|
|
41
|
+
"checkedは各要素にFirestoreへ実際に保存されている生の値であり、" +
|
|
42
|
+
"「祖先がチェック済みなら配下も完了とみなす」という加工は行っていない。" +
|
|
43
|
+
"そう解釈したい場合は、返ってきたツリーを見てこちら(呼び出す側)で判断すること。" +
|
|
44
|
+
"マップの中身には秘匿情報が含まれ得るため、初めて読むマップでは呼び出し前に" +
|
|
45
|
+
"ユーザー本人の許可が必要。confirmed: trueを付けずに呼ぶと、このツールは" +
|
|
46
|
+
"実データを返す代わりに確認を促すメッセージだけを返すので、その指示に従って" +
|
|
47
|
+
"ユーザーに直接確認してからconfirmed: trueで呼び直すこと。" +
|
|
48
|
+
"アプリ側での直接の編集はこのツールを呼ぶまで反映されないため、" +
|
|
49
|
+
"ユーザーから進捗や状態を尋ねられたときは記憶に頼らず必ず呼び直すこと。", {
|
|
50
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
51
|
+
confirmed: z
|
|
52
|
+
.boolean()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("ユーザー本人に直接「中身を確認してよいか」と尋ね、同意を得てからtrueを付けて呼ぶこと。" +
|
|
55
|
+
"確認前にtrueを付けてはならない。"),
|
|
56
|
+
}, async ({ mapId, confirmed }) => {
|
|
57
|
+
const { client, session } = getContext();
|
|
58
|
+
const map = await resolveMap(client, session, mapId);
|
|
59
|
+
// マップ単位のAI読み取り許可(aiReadPermission)。このフィールドは
|
|
60
|
+
// アプリのマップ編集画面からのみ変更でき、MCPサーバーのどの
|
|
61
|
+
// ツールからも書き込まない(AIが自分自身に許可を与えられると
|
|
62
|
+
// 確認の仕組みが意味を失うため)。
|
|
63
|
+
if (map.header.aiReadPermission === "deny") {
|
|
64
|
+
throw new AiReadDeniedError(map.header.title);
|
|
65
|
+
}
|
|
66
|
+
const alwaysAllowed = map.header.aiReadPermission === "allow";
|
|
67
|
+
// 未確認のマップでは、確認を促す文面だけを返して終える。levelsの
|
|
68
|
+
// 実データはこのゲートを通過するまで一切読みに行かない
|
|
69
|
+
// (以前はlevels/rootの存在確認のために先に読んでしまっていたが、
|
|
70
|
+
// 「実際の要素を返す前に必ず本人の明示的な許可を得る」という設計
|
|
71
|
+
// 意図に反するため、確認ゲートの後に読む順序へ戻した)。
|
|
72
|
+
if (!alwaysAllowed && !confirmedMapIds.has(mapId)) {
|
|
73
|
+
if (confirmed === true) {
|
|
74
|
+
confirmedMapIds.add(mapId);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
return { content: [{ type: "text", text: buildConfirmationRequestText(map.header.title) }] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const levelsById = await readAllLevelDocs(client, levelsPathOf(map));
|
|
81
|
+
assertLevelsInitialized(levelsById, map.header.title);
|
|
82
|
+
const tree = buildTreeFromLevels(levelsById);
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: JSON.stringify({
|
|
88
|
+
mapId: map.headerId,
|
|
89
|
+
title: map.header.title,
|
|
90
|
+
isTodo: map.header.isTodo,
|
|
91
|
+
elements: toChecked(tree),
|
|
92
|
+
}, null, 2),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getContext } from "../mcpContext.js";
|
|
2
|
+
import { canEditHeader, headerFromFirestore } from "../domain/header.js";
|
|
3
|
+
export function registerListMaps(server) {
|
|
4
|
+
server.tool("list_maps", "自分のMatryoshka Mind Mapの全マップ(タイトル・ID・TODOかどうか)を返す。" +
|
|
5
|
+
"他のツールでmapIdを指定する前に、まずこれで存在するマップとそのIDを確認する。" +
|
|
6
|
+
"自分が所有するマップに加え、他のユーザーから共有されて見えているマップも含む" +
|
|
7
|
+
"(共有マップのroleが'viewer'の場合は閲覧のみで、書き込み系ツールは使えない)。" +
|
|
8
|
+
"アプリ側での直接の編集(新規マップ作成等)はこのツールを呼ぶまで反映されないため、" +
|
|
9
|
+
"ユーザーから状態を尋ねられたときは記憶に頼らず必ず呼び直すこと。", {}, async () => {
|
|
10
|
+
const { client, session } = getContext();
|
|
11
|
+
const own = await client.listDocuments(`users/${session.uid}/headers`);
|
|
12
|
+
const ownMaps = own.map((doc) => {
|
|
13
|
+
const header = headerFromFirestore(doc.id, session.uid, doc.data);
|
|
14
|
+
return {
|
|
15
|
+
id: header.id,
|
|
16
|
+
title: header.title,
|
|
17
|
+
isTodo: header.isTodo,
|
|
18
|
+
shared: false,
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
const refs = await client.listDocuments(`users/${session.uid}/refs`);
|
|
22
|
+
const sharedMaps = (await Promise.all(refs.map(async (ref) => {
|
|
23
|
+
const ownerUid = ref.data.userId;
|
|
24
|
+
const headerId = ref.data.headerId;
|
|
25
|
+
if (typeof ownerUid !== "string" || typeof headerId !== "string") {
|
|
26
|
+
return null; // 壊れたrefsは無視する
|
|
27
|
+
}
|
|
28
|
+
const doc = await client.getDocument(`users/${ownerUid}/headers/${headerId}`);
|
|
29
|
+
if (!doc)
|
|
30
|
+
return null; // 共有元で削除済み
|
|
31
|
+
const header = headerFromFirestore(headerId, ownerUid, doc.data);
|
|
32
|
+
const ownerDoc = await client.getDocument(`users/${ownerUid}`);
|
|
33
|
+
return {
|
|
34
|
+
id: header.id,
|
|
35
|
+
title: header.title,
|
|
36
|
+
isTodo: header.isTodo,
|
|
37
|
+
shared: true,
|
|
38
|
+
role: canEditHeader(header, session.uid, session.email) ? "editor" : "viewer",
|
|
39
|
+
ownerEmail: ownerDoc?.data.email ?? null,
|
|
40
|
+
};
|
|
41
|
+
}))).filter((m) => m !== null);
|
|
42
|
+
const maps = [...ownMaps, ...sharedMaps];
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: JSON.stringify({ maps }, null, 2) }],
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
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";
|
|
9
|
+
export function registerMoveElement(server) {
|
|
10
|
+
server.tool("move_element", "既存マップの要素1件(配下ごと)を、同じマップ内の別の場所へ移動する。" +
|
|
11
|
+
"階層をまたいだ移動(昇格・降格)も、同じ階層内の並べ替えも、これ1つで行える。", {
|
|
12
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
13
|
+
elementId: z.string().describe("移動する要素のID"),
|
|
14
|
+
newParentElementId: z
|
|
15
|
+
.string()
|
|
16
|
+
.nullable()
|
|
17
|
+
.describe("移動先の親要素のID。マップの最上位へ移動する場合はnull"),
|
|
18
|
+
afterElementId: z
|
|
19
|
+
.string()
|
|
20
|
+
.nullable()
|
|
21
|
+
.describe("移動先の中で、このIDの要素の直後に置く。先頭に置く場合はnull"),
|
|
22
|
+
}, async ({ mapId, elementId, newParentElementId, afterElementId }) => {
|
|
23
|
+
const { client, session } = getContext();
|
|
24
|
+
const map = await resolveMap(client, session, mapId);
|
|
25
|
+
assertCanEditMap(map, session);
|
|
26
|
+
const levelsPath = levelsPathOf(map);
|
|
27
|
+
const bodiesPath = bodiesPathOf(map);
|
|
28
|
+
assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
|
|
29
|
+
if (elementId === newParentElementId) {
|
|
30
|
+
throw new Error("要素を自分自身の子にすることはできません。");
|
|
31
|
+
}
|
|
32
|
+
if (elementId === afterElementId) {
|
|
33
|
+
throw new Error("afterElementIdにelementId自身は指定できません。");
|
|
34
|
+
}
|
|
35
|
+
if (await isLevelsOnly(client)) {
|
|
36
|
+
let levelsPathResult = null;
|
|
37
|
+
await client.runOptimistic(async () => {
|
|
38
|
+
const snapshot = await readLevelsSnapshot(client, levelsPath, map.header.title);
|
|
39
|
+
if (afterElementId !== null && !locateInLevels(snapshot, afterElementId)) {
|
|
40
|
+
throw new Error(`afterElementId(${afterElementId})がこのマップに見つかりません。`);
|
|
41
|
+
}
|
|
42
|
+
const writes = buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParentElementId, afterElementId);
|
|
43
|
+
levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, newParentElementId));
|
|
44
|
+
return writes;
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
let path = null;
|
|
51
|
+
await client.runOptimistic(async () => {
|
|
52
|
+
const existingDocs = await client.listDocuments(bodiesPath);
|
|
53
|
+
const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
|
|
54
|
+
const byId = new Map(existingBodies.map((b) => [b.id, b]));
|
|
55
|
+
const body = byId.get(elementId);
|
|
56
|
+
if (!body) {
|
|
57
|
+
throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
58
|
+
}
|
|
59
|
+
if (newParentElementId !== null && !byId.has(newParentElementId)) {
|
|
60
|
+
throw new Error(`newParentElementId(${newParentElementId})がこのマップに見つかりません。`);
|
|
61
|
+
}
|
|
62
|
+
if (afterElementId !== null && !byId.has(afterElementId)) {
|
|
63
|
+
throw new Error(`afterElementId(${afterElementId})がこのマップに見つかりません。`);
|
|
64
|
+
}
|
|
65
|
+
// 移動先が自分自身の配下(子孫)だと、木構造が壊れる(循環になる)。
|
|
66
|
+
for (let cur = newParentElementId; cur !== null; cur = byId.get(cur)?.parent ?? null) {
|
|
67
|
+
if (cur === elementId) {
|
|
68
|
+
throw new Error("要素を自分自身の配下(子孫)の中へ移動することはできません。");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const writes = await buildMoveElementWrites(client, bodiesPath, levelsPath, existingBodies, elementId, newParentElementId, afterElementId);
|
|
72
|
+
path = formatPath(map.header.title, newParentElementId === null
|
|
73
|
+
? []
|
|
74
|
+
: ancestorDetails(existingBodies, newParentElementId));
|
|
75
|
+
return writes;
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: JSON.stringify({ path }, null, 2) }],
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
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";
|
|
9
|
+
export function registerUpdateTaskStatus(server) {
|
|
10
|
+
server.tool("update_task_status", "TODOマップの要素のチェック状態を書き換える。1件でも複数件でもよい。" +
|
|
11
|
+
"「親をチェックすると配下も完了扱いになる」というアプリ内の表示上の挙動は" +
|
|
12
|
+
"このツールでは再現しない(指定した要素だけを書き換える、配下への自動連鎖はしない)。" +
|
|
13
|
+
"配下も揃えたい場合は、対象の要素をすべてupdatesに列挙すること。", {
|
|
14
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
15
|
+
updates: z
|
|
16
|
+
.array(z.object({
|
|
17
|
+
elementId: z.string().describe("list_elementsで得た要素のID"),
|
|
18
|
+
checked: z.boolean().describe("設定するチェック状態"),
|
|
19
|
+
}))
|
|
20
|
+
.min(1),
|
|
21
|
+
}, async ({ mapId, updates }) => {
|
|
22
|
+
const { client, session } = getContext();
|
|
23
|
+
const map = await resolveMap(client, session, mapId);
|
|
24
|
+
assertCanEditMap(map, session);
|
|
25
|
+
if (!map.header.isTodo) {
|
|
26
|
+
throw new Error(`「${map.header.title}」はTODOマップではないため、チェック状態を持ちません。`);
|
|
27
|
+
}
|
|
28
|
+
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, map.header.title);
|
|
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 = [];
|
|
54
|
+
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]);
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }],
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|