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,93 @@
|
|
|
1
|
+
import { buildInsertChildWrite, buildReorderChildWrite, buildRemoveChildrenWrite } from "./levels.js";
|
|
2
|
+
import { mergeWritesByPath } from "../firestore/restClient.js";
|
|
3
|
+
/**
|
|
4
|
+
* 要素1件(配下ごと)を新しい場所へ移動するための書き込みを組み立てる。
|
|
5
|
+
* tools/moveElement.tsから切り出した純粋寄りの関数(client.getDocumentだけ呼ぶ)。
|
|
6
|
+
* 呼び出し側で自己参照・循環などのバリデーションを済ませ、[elementId]が
|
|
7
|
+
* [existingBodies]に存在することを保証しておくこと。
|
|
8
|
+
*/
|
|
9
|
+
export async function buildMoveElementWrites(client, bodiesPath, levelsPath, existingBodies, elementId, newParentElementId, afterElementId) {
|
|
10
|
+
const byId = new Map(existingBodies.map((b) => [b.id, b]));
|
|
11
|
+
const body = byId.get(elementId);
|
|
12
|
+
if (!body) {
|
|
13
|
+
throw new Error(`elementId(${elementId})がこのマップに見つかりません。`);
|
|
14
|
+
}
|
|
15
|
+
const writes = [];
|
|
16
|
+
// 1) 元の場所の連結リストを繋ぎ直す
|
|
17
|
+
if (body.prev !== null) {
|
|
18
|
+
writes.push({
|
|
19
|
+
path: `${bodiesPath}/${body.prev}`,
|
|
20
|
+
fields: { next: body.next },
|
|
21
|
+
requireUpdateTime: byId.get(body.prev)?.updateTime,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
else if (body.parent !== null) {
|
|
25
|
+
writes.push({
|
|
26
|
+
path: `${bodiesPath}/${body.parent}`,
|
|
27
|
+
fields: { child: body.next },
|
|
28
|
+
requireUpdateTime: byId.get(body.parent)?.updateTime,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (body.next !== null) {
|
|
32
|
+
writes.push({
|
|
33
|
+
path: `${bodiesPath}/${body.next}`,
|
|
34
|
+
fields: { prev: body.prev },
|
|
35
|
+
requireUpdateTime: byId.get(body.next)?.updateTime,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// 2) 新しい場所へ挿入する位置を求める(afterElementIdが実際にnewParentElementId
|
|
39
|
+
// の子でなければ、見つからない扱い=末尾として無視する)。
|
|
40
|
+
const newSiblingsExcludingSelf = existingBodies.filter((b) => b.parent === newParentElementId && b.id !== elementId);
|
|
41
|
+
const afterBody = afterElementId !== null
|
|
42
|
+
? newSiblingsExcludingSelf.find((b) => b.id === afterElementId) ?? null
|
|
43
|
+
: null;
|
|
44
|
+
const newPrevId = afterElementId === null ? null : afterBody?.id ?? null;
|
|
45
|
+
const newNextId = newPrevId === null
|
|
46
|
+
? newSiblingsExcludingSelf.find((b) => b.prev === null)?.id ?? null
|
|
47
|
+
: newSiblingsExcludingSelf.find((b) => b.prev === newPrevId)?.id ?? null;
|
|
48
|
+
writes.push({
|
|
49
|
+
path: `${bodiesPath}/${elementId}`,
|
|
50
|
+
fields: { parent: newParentElementId, prev: newPrevId, next: newNextId },
|
|
51
|
+
requireUpdateTime: body.updateTime,
|
|
52
|
+
});
|
|
53
|
+
if (newPrevId !== null) {
|
|
54
|
+
writes.push({
|
|
55
|
+
path: `${bodiesPath}/${newPrevId}`,
|
|
56
|
+
fields: { next: elementId },
|
|
57
|
+
requireUpdateTime: byId.get(newPrevId)?.updateTime,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else if (newParentElementId !== null) {
|
|
61
|
+
writes.push({
|
|
62
|
+
path: `${bodiesPath}/${newParentElementId}`,
|
|
63
|
+
fields: { child: elementId },
|
|
64
|
+
requireUpdateTime: byId.get(newParentElementId)?.updateTime,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (newNextId !== null) {
|
|
68
|
+
writes.push({
|
|
69
|
+
path: `${bodiesPath}/${newNextId}`,
|
|
70
|
+
fields: { prev: elementId },
|
|
71
|
+
requireUpdateTime: byId.get(newNextId)?.updateTime,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// 3) levels側: 元の親から取り除き、新しい親へ挿入する
|
|
75
|
+
const oldLevelId = body.parent ?? "root";
|
|
76
|
+
const newLevelId = newParentElementId ?? "root";
|
|
77
|
+
const entry = { id: elementId, detail: body.detail, done: body.done };
|
|
78
|
+
if (oldLevelId === newLevelId) {
|
|
79
|
+
// **同じ親の中での並べ替えは1つの書き込みにまとめること。**
|
|
80
|
+
// remove用とinsert用を別々に作ると、どちらも「元のchildren」を
|
|
81
|
+
// 読んでから組み立てるため、同じドキュメントへの書き込みが2つでき、
|
|
82
|
+
// 後勝ちのinsert側だけが残る。insert側は対象がまだ入ったままの配列に
|
|
83
|
+
// もう1つ足したものなので、要素が重複する(実データで踏んだ)。
|
|
84
|
+
writes.push(await buildReorderChildWrite(client, levelsPath, oldLevelId, entry, newPrevId));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const removeWrite = await buildRemoveChildrenWrite(client, levelsPath, oldLevelId, [elementId]);
|
|
88
|
+
if (removeWrite)
|
|
89
|
+
writes.push(removeWrite);
|
|
90
|
+
writes.push(await buildInsertChildWrite(client, levelsPath, newLevelId, entry, newPrevId));
|
|
91
|
+
}
|
|
92
|
+
return mergeWritesByPath(writes);
|
|
93
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// lib/main.dartのFirebaseOptions(Web版)と同じ値。
|
|
2
|
+
// Web用のAPIキーだが、Identity Toolkit REST APIの呼び出しには
|
|
3
|
+
// プラットフォームを問わず同じキーを使える。
|
|
4
|
+
export const FIREBASE_API_KEY = "AIzaSyCp1EMDbIcYHJXtkZiUYJH0yo_4U366WqQ";
|
|
5
|
+
export const FIREBASE_PROJECT_ID = "matryoshkamap";
|
|
6
|
+
export const FIREBASE_AUTH_DOMAIN = "matryoshkamap.firebaseapp.com";
|
|
7
|
+
// Googleログイン(ローカルループバック方式)用の共有OAuthクライアント
|
|
8
|
+
// (種類:デスクトップアプリ)。インストール型アプリのクライアントシークレットは
|
|
9
|
+
// Google自身がconfidentialとして扱わない設計のため(gcloud CLI等と同じ)、
|
|
10
|
+
// 全ユーザーのCLIで共有してよい。ユーザーごとにGoogle Cloud Consoleで
|
|
11
|
+
// 作り直す必要は無い。
|
|
12
|
+
export const GOOGLE_OAUTH_CLIENT_ID = "1020142389980-s55ds6qtude3v5e5piqpmuh24u5ktf4u.apps.googleusercontent.com";
|
|
13
|
+
export const GOOGLE_OAUTH_CLIENT_SECRET = "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";
|
|
14
|
+
// Sign in with AppleのServices ID(Web/Android用、Bundle IDと別)。
|
|
15
|
+
// アプリ本体が使っているものと同じServices IDを、MCPのCLIも再利用する
|
|
16
|
+
// (Return URLに中継ページのURLを追加登録するだけでよい)。
|
|
17
|
+
export const APPLE_SERVICES_ID = "jp.spheres.mm.signin";
|
|
18
|
+
// client_secret(JWT)発行用のCloud Function(functions/src/index.ts)。
|
|
19
|
+
// Sign in with Appleの秘密鍵(.p8)はアプリを識別するためのものであり、
|
|
20
|
+
// ユーザーごとに異なるものではないため、鍵自体を各ユーザーに配る代わりに、
|
|
21
|
+
// 署名専用のこのエンドポイントを全ユーザーのCLIから共有で呼び出す。
|
|
22
|
+
export const APPLE_CLIENT_SECRET_FUNCTION_URL = "https://asia-northeast1-matryoshkamap.cloudfunctions.net/appleClientSecret";
|
|
23
|
+
// Apple中継ページの本番URL。web/mcp-apple-relay.html をビルド・デプロイした先。
|
|
24
|
+
// このURLをApple Developer PortalのServices ID設定のReturn URLに
|
|
25
|
+
// 追加登録する必要がある。
|
|
26
|
+
//
|
|
27
|
+
// ドメインは.web.appではなく.firebaseappを使うこと。Appleの
|
|
28
|
+
// Return URL登録は「Domains and Subdomains」への事前登録も必要で、
|
|
29
|
+
// アプリ本体のApple連携(Return URL:
|
|
30
|
+
// https://matryoshkamap.firebaseapp.com/__/auth/handler)により
|
|
31
|
+
// matryoshkamap.firebaseappは既に登録済みのため、新規のドメイン登録なしで
|
|
32
|
+
// Return URLの追加だけで済む。.web.appは別ドメイン扱いになり
|
|
33
|
+
// invalid_request(Invalid web redirect url)になることを実機で確認済み。
|
|
34
|
+
export const APPLE_RELAY_URL = "https://matryoshkamap.firebaseapp.com/mcp-apple-relay.html";
|
|
35
|
+
// web/mcp-apple-relay.html のLOOPBACK_PORTと必ず一致させること。
|
|
36
|
+
export const APPLE_LOOPBACK_PORT = 51004;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { FIREBASE_PROJECT_ID } from "../firebaseConfig.js";
|
|
2
|
+
import { decodeFields, encodeFields, encodeValue, lastPathSegment, } from "./valueCodec.js";
|
|
3
|
+
const BASE_URL = `https://firestore.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/databases/(default)/documents`;
|
|
4
|
+
export class FirestoreError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
raw;
|
|
7
|
+
constructor(message, status, raw) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.raw = raw;
|
|
11
|
+
this.name = "FirestoreError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function encodePath(path) {
|
|
15
|
+
return path
|
|
16
|
+
.split("/")
|
|
17
|
+
.map((segment) => encodeURIComponent(segment))
|
|
18
|
+
.join("/");
|
|
19
|
+
}
|
|
20
|
+
/** レスポンスがJSONでない(障害時のHTMLエラーページ等)場合でも例外にせず読む。 */
|
|
21
|
+
async function parseJsonResponse(res) {
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
try {
|
|
24
|
+
return text ? JSON.parse(text) : {};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { error: { message: text || `HTTP ${res.status}` } };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Firestoreの1コミット(:commit)に含められる書き込み件数の上限(500)より余裕を持たせた値。 */
|
|
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
|
+
export class FirestoreRestClient {
|
|
72
|
+
session;
|
|
73
|
+
constructor(session) {
|
|
74
|
+
this.session = session;
|
|
75
|
+
}
|
|
76
|
+
async headers() {
|
|
77
|
+
const idToken = await this.session.getValidIdToken();
|
|
78
|
+
return {
|
|
79
|
+
Authorization: `Bearer ${idToken}`,
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async getDocument(path) {
|
|
84
|
+
const res = await fetch(`${BASE_URL}/${encodePath(path)}`, {
|
|
85
|
+
headers: await this.headers(),
|
|
86
|
+
});
|
|
87
|
+
if (res.status === 404)
|
|
88
|
+
return null;
|
|
89
|
+
const data = await parseJsonResponse(res);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
id: lastPathSegment(data.name),
|
|
95
|
+
data: decodeFields(data.fields ?? {}),
|
|
96
|
+
updateTime: data.updateTime,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* [operation]を実行し、返された[FirestoreWrite]をコミットする。[operation]
|
|
101
|
+
* が返す書き込みに[FirestoreWrite.requireUpdateTime]が含まれ、コミット時点で
|
|
102
|
+
* 対象ドキュメントが読み取り後に他から変更されていた場合(FAILED_PRECONDITION)、
|
|
103
|
+
* [operation]の読み取りからやり直して最大[maxAttempts]回リトライする。
|
|
104
|
+
*
|
|
105
|
+
* Firestoreの明示的なトランザクション(:beginTransaction)はFirebase Authの
|
|
106
|
+
* IDトークンではIAM権限が無く403になるため使えない(2026-09-09に実アカウントで
|
|
107
|
+
* 確認)。代わりにcurrentDocument.updateTimeによる楽観的ロックで
|
|
108
|
+
* read-modify-write競合を検出する。
|
|
109
|
+
*/
|
|
110
|
+
async runOptimistic(operation, maxAttempts = 5) {
|
|
111
|
+
let lastError;
|
|
112
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
113
|
+
const writes = await operation();
|
|
114
|
+
try {
|
|
115
|
+
await this.commitWrites(writes);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
lastError = e;
|
|
120
|
+
const raw = e instanceof FirestoreError ? e.raw : undefined;
|
|
121
|
+
const isPreconditionFailed = raw?.error?.status === "FAILED_PRECONDITION";
|
|
122
|
+
if (!isPreconditionFailed) {
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
// 読み取り後に他の書き込みが割り込んだ。少し待って
|
|
126
|
+
// 最初から(読み取りも含めて)やり直す。
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw lastError instanceof Error
|
|
131
|
+
? lastError
|
|
132
|
+
: new Error("runOptimistic: リトライ上限に達した");
|
|
133
|
+
}
|
|
134
|
+
/** コレクション配下の全ドキュメントを取得する(ページングを自動で辿る)。 */
|
|
135
|
+
async listDocuments(collectionPath) {
|
|
136
|
+
const results = [];
|
|
137
|
+
let pageToken;
|
|
138
|
+
do {
|
|
139
|
+
const url = new URL(`${BASE_URL}/${encodePath(collectionPath)}`);
|
|
140
|
+
url.searchParams.set("pageSize", "300");
|
|
141
|
+
if (pageToken)
|
|
142
|
+
url.searchParams.set("pageToken", pageToken);
|
|
143
|
+
const res = await fetch(url, { headers: await this.headers() });
|
|
144
|
+
const data = await parseJsonResponse(res);
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
147
|
+
}
|
|
148
|
+
for (const doc of data.documents ?? []) {
|
|
149
|
+
results.push({
|
|
150
|
+
id: lastPathSegment(doc.name),
|
|
151
|
+
data: decodeFields(doc.fields ?? {}),
|
|
152
|
+
updateTime: doc.updateTime,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
pageToken = data.nextPageToken;
|
|
156
|
+
} while (pageToken);
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
/** サーバー採番のIDで新規ドキュメントを作る。 */
|
|
160
|
+
async createDocument(collectionPath, fields) {
|
|
161
|
+
const res = await fetch(`${BASE_URL}/${encodePath(collectionPath)}`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: await this.headers(),
|
|
164
|
+
body: JSON.stringify({ fields: encodeFields(fields) }),
|
|
165
|
+
});
|
|
166
|
+
const data = await parseJsonResponse(res);
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
169
|
+
}
|
|
170
|
+
return { id: lastPathSegment(data.name), data: decodeFields(data.fields ?? {}) };
|
|
171
|
+
}
|
|
172
|
+
/** 指定パスに、全フィールドを置き換える形で書き込む(無ければ作成)。 */
|
|
173
|
+
async setDocument(docPath, fields) {
|
|
174
|
+
const res = await fetch(`${BASE_URL}/${encodePath(docPath)}`, {
|
|
175
|
+
method: "PATCH",
|
|
176
|
+
headers: await this.headers(),
|
|
177
|
+
body: JSON.stringify({ fields: encodeFields(fields) }),
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const data = await parseJsonResponse(res);
|
|
181
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** 指定したフィールドだけを部分更新する。 */
|
|
185
|
+
async updateDocument(docPath, fields) {
|
|
186
|
+
const mask = Object.keys(fields).map((key) => `updateMask.fieldPaths=${encodeURIComponent(key)}`);
|
|
187
|
+
const url = `${BASE_URL}/${encodePath(docPath)}?${mask.join("&")}`;
|
|
188
|
+
const res = await fetch(url, {
|
|
189
|
+
method: "PATCH",
|
|
190
|
+
headers: await this.headers(),
|
|
191
|
+
body: JSON.stringify({ fields: encodeFields(fields) }),
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
const data = await parseJsonResponse(res);
|
|
195
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async deleteDocument(docPath) {
|
|
199
|
+
const res = await fetch(`${BASE_URL}/${encodePath(docPath)}`, {
|
|
200
|
+
method: "DELETE",
|
|
201
|
+
headers: await this.headers(),
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok && res.status !== 404) {
|
|
204
|
+
const data = await parseJsonResponse(res);
|
|
205
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* 複数ドキュメントへの部分更新・削除をまとめて行う。
|
|
210
|
+
* マインドマップの連結リスト(prev/next/parent/child)を書き換える操作は
|
|
211
|
+
* 一部だけ成功すると整合性が壊れるため、必ずこれを使う。
|
|
212
|
+
*
|
|
213
|
+
* Firestoreの:commitは1リクエストあたり最大500件の書き込みまでしか
|
|
214
|
+
* 受け付けないため、それを超える場合は複数回のコミットに分割する
|
|
215
|
+
* (分割した場合、コミット単位はそれぞれ独立したトランザクションになる)。
|
|
216
|
+
*/
|
|
217
|
+
async commitWrites(writes) {
|
|
218
|
+
for (let i = 0; i < writes.length; i += COMMIT_CHUNK_SIZE) {
|
|
219
|
+
await this.commitChunk(writes.slice(i, i + COMMIT_CHUNK_SIZE));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async commitChunk(writes) {
|
|
223
|
+
if (writes.length === 0)
|
|
224
|
+
return;
|
|
225
|
+
const body = {
|
|
226
|
+
writes: writes.map((w) => {
|
|
227
|
+
const name = `projects/${FIREBASE_PROJECT_ID}/databases/(default)/documents/${w.path}`;
|
|
228
|
+
const currentDocument = w.requireUpdateTime
|
|
229
|
+
? { currentDocument: { updateTime: w.requireUpdateTime } }
|
|
230
|
+
: w.requireMissing
|
|
231
|
+
? { currentDocument: { exists: false } }
|
|
232
|
+
: {};
|
|
233
|
+
if (w.delete) {
|
|
234
|
+
return { delete: name, ...currentDocument };
|
|
235
|
+
}
|
|
236
|
+
if (w.arrayUnion) {
|
|
237
|
+
return {
|
|
238
|
+
transform: {
|
|
239
|
+
document: name,
|
|
240
|
+
fieldTransforms: [
|
|
241
|
+
{
|
|
242
|
+
fieldPath: w.arrayUnion.field,
|
|
243
|
+
appendMissingElements: { values: w.arrayUnion.values.map(encodeValue) },
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const fields = w.fields ?? {};
|
|
250
|
+
return {
|
|
251
|
+
update: { name, fields: encodeFields(fields) },
|
|
252
|
+
updateMask: { fieldPaths: Object.keys(fields) },
|
|
253
|
+
...currentDocument,
|
|
254
|
+
};
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
const res = await fetch(`${BASE_URL}:commit`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: await this.headers(),
|
|
260
|
+
body: JSON.stringify(body),
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok) {
|
|
263
|
+
const data = await parseJsonResponse(res);
|
|
264
|
+
throw new FirestoreError(data?.error?.message ?? `HTTP ${res.status}`, res.status, data);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Firestore REST API (https://firestore.googleapis.com/v1/...) の
|
|
2
|
+
// 型付きValue表現と、JSのプレーンな値との相互変換。
|
|
3
|
+
export function encodeValue(value) {
|
|
4
|
+
if (value === null || value === undefined) {
|
|
5
|
+
return { nullValue: null };
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === "boolean") {
|
|
8
|
+
return { booleanValue: value };
|
|
9
|
+
}
|
|
10
|
+
if (typeof value === "number") {
|
|
11
|
+
return Number.isInteger(value)
|
|
12
|
+
? { integerValue: String(value) }
|
|
13
|
+
: { doubleValue: value };
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === "string") {
|
|
16
|
+
return { stringValue: value };
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return { arrayValue: { values: value.map(encodeValue) } };
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === "object") {
|
|
22
|
+
return { mapValue: { fields: encodeFields(value) } };
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`encodeValue: 未対応の型です: ${typeof value}`);
|
|
25
|
+
}
|
|
26
|
+
export function decodeValue(value) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return null;
|
|
29
|
+
if ("nullValue" in value)
|
|
30
|
+
return null;
|
|
31
|
+
if ("booleanValue" in value)
|
|
32
|
+
return value.booleanValue;
|
|
33
|
+
if ("integerValue" in value)
|
|
34
|
+
return Number(value.integerValue);
|
|
35
|
+
if ("doubleValue" in value)
|
|
36
|
+
return value.doubleValue;
|
|
37
|
+
if ("stringValue" in value)
|
|
38
|
+
return value.stringValue;
|
|
39
|
+
if ("arrayValue" in value) {
|
|
40
|
+
return (value.arrayValue.values ?? []).map(decodeValue);
|
|
41
|
+
}
|
|
42
|
+
if ("mapValue" in value) {
|
|
43
|
+
return decodeFields(value.mapValue.fields ?? {});
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
export function encodeFields(obj) {
|
|
48
|
+
const result = {};
|
|
49
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
50
|
+
result[key] = encodeValue(value);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export function decodeFields(fields) {
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
57
|
+
result[key] = decodeValue(value);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
/** "projects/p/databases/(default)/documents/users/uid/headers/hid" からドキュメントIDだけ取り出す。 */
|
|
62
|
+
export function lastPathSegment(name) {
|
|
63
|
+
const parts = name.split("/");
|
|
64
|
+
return parts[parts.length - 1];
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { loadSession } from "./auth/session.js";
|
|
2
|
+
import { FirestoreRestClient } from "./firestore/restClient.js";
|
|
3
|
+
let cached = null;
|
|
4
|
+
/** サインイン済みセッションとFirestoreクライアントを1つだけ用意し、使い回す。 */
|
|
5
|
+
export function getContext() {
|
|
6
|
+
if (!cached) {
|
|
7
|
+
const session = loadSession();
|
|
8
|
+
cached = { session, client: new FirestoreRestClient(session) };
|
|
9
|
+
}
|
|
10
|
+
return cached;
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerListMaps } from "./tools/listMaps.js";
|
|
5
|
+
import { registerListElements } from "./tools/listElements.js";
|
|
6
|
+
import { registerCreateMap } from "./tools/createMap.js";
|
|
7
|
+
import { registerAutoStructureThought } from "./tools/autoStructureThought.js";
|
|
8
|
+
import { registerUpdateTaskStatus } from "./tools/updateTaskStatus.js";
|
|
9
|
+
import { registerImportMarkdownContext } from "./tools/importMarkdownContext.js";
|
|
10
|
+
import { registerEditElement } from "./tools/editElement.js";
|
|
11
|
+
import { registerDeleteElements } from "./tools/deleteElements.js";
|
|
12
|
+
import { registerMoveElement } from "./tools/moveElement.js";
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "matryoshka-mindmap",
|
|
15
|
+
version: "0.1.0",
|
|
16
|
+
}, {
|
|
17
|
+
instructions: "このサーバーはリアルタイム同期を行わない。各ツールは呼び出された瞬間の" +
|
|
18
|
+
"Firestoreの状態を返すだけで、アプリ側でユーザーが直接行った変更は" +
|
|
19
|
+
"自動的には通知されない。ユーザーから進捗や現在の状態を尋ねられたときは、" +
|
|
20
|
+
"それ以前の会話で見た内容を記憶に頼って答えず、必ずlist_maps/list_elementsを" +
|
|
21
|
+
"呼び直して最新の状態を確認してから答えること。",
|
|
22
|
+
});
|
|
23
|
+
registerListMaps(server);
|
|
24
|
+
registerListElements(server);
|
|
25
|
+
registerCreateMap(server);
|
|
26
|
+
registerAutoStructureThought(server);
|
|
27
|
+
registerUpdateTaskStatus(server);
|
|
28
|
+
registerImportMarkdownContext(server);
|
|
29
|
+
registerEditElement(server);
|
|
30
|
+
registerDeleteElements(server);
|
|
31
|
+
registerMoveElement(server);
|
|
32
|
+
const transport = new StdioServerTransport();
|
|
33
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { buildForestFromTree, buildAppendWrites } from "../domain/appendForest.js";
|
|
9
|
+
import { assertLevelsRootExists, buildAppendLevelWrites, levelsDocExists } from "../domain/levels.js";
|
|
10
|
+
const treeNodeSchema = z.lazy(() => z.object({
|
|
11
|
+
detail: z.string().min(1).describe("要素の本文"),
|
|
12
|
+
done: z.boolean().optional().describe("TODOマップのときのチェック状態(省略時false)"),
|
|
13
|
+
children: z.array(treeNodeSchema).optional().describe("この要素の内側に入れ子にする子要素"),
|
|
14
|
+
}));
|
|
15
|
+
export function registerAutoStructureThought(server) {
|
|
16
|
+
server.tool("auto_structure_thought", "会話中にClaudeが考えた階層構造を、既存のマップに追加する。深さは3階層固定ではなく任意。" +
|
|
17
|
+
"構造化のロジック(何をどう分解するか)はClaude側の判断で行い、このツールはFirestoreへの" +
|
|
18
|
+
"書き込みだけを行う。追加先(parentElementId)はlist_elementsの結果を見て判断すること。" +
|
|
19
|
+
"対象は既存マップのみ(新規作成が必要ならcreate_mapを先に呼ぶ)。", {
|
|
20
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
21
|
+
parentElementId: z
|
|
22
|
+
.string()
|
|
23
|
+
.nullable()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("この要素の内側(子)として追加する。マップの最上位に追加する場合はnullまたは省略"),
|
|
26
|
+
nodes: z.array(treeNodeSchema).min(1).describe("追加する階層構造(ルートの配列)"),
|
|
27
|
+
}, async ({ mapId, parentElementId, nodes }) => {
|
|
28
|
+
const { client, session } = getContext();
|
|
29
|
+
const map = await resolveMap(client, session, mapId);
|
|
30
|
+
assertCanEditMap(map, session);
|
|
31
|
+
const levelsPath = levelsPathOf(map);
|
|
32
|
+
const bodiesPath = bodiesPathOf(map);
|
|
33
|
+
const targetParentId = parentElementId ?? null;
|
|
34
|
+
const forest = buildForestFromTree(nodes);
|
|
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, forest, targetParentId, map.header.isTodo);
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: JSON.stringify({ path: levelsPathResult, addedElementCount: forest.length }, null, 2),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// 読み取り(bodies一覧・levels存在確認)から書き込みまでを楽観的
|
|
55
|
+
// ロックでまとめる。読み取り後に対象ドキュメントが他から変更される
|
|
56
|
+
// とコミットがFAILED_PRECONDITIONになり、読み取りからやり直す。
|
|
57
|
+
let createdIds = [];
|
|
58
|
+
let existingBodiesForPath = [];
|
|
59
|
+
await client.runOptimistic(async () => {
|
|
60
|
+
assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
|
|
61
|
+
const existingDocs = await client.listDocuments(bodiesPath);
|
|
62
|
+
const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
|
|
63
|
+
existingBodiesForPath = existingBodies;
|
|
64
|
+
if (targetParentId !== null && !existingBodies.some((b) => b.id === targetParentId)) {
|
|
65
|
+
throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
66
|
+
}
|
|
67
|
+
const { writes, createdIds: ids } = buildAppendWrites(bodiesPath, existingBodies, forest, targetParentId, map.header.isTodo);
|
|
68
|
+
createdIds = ids;
|
|
69
|
+
// 挿入先のlevelsドキュメントが実際に存在するかを直接確認する
|
|
70
|
+
// (bodies側の状態から推測すると、levelsとbodiesが食い違っている
|
|
71
|
+
// 場合にarrayUnion書き込みが「ドキュメントが無い」で失敗しうるため)。
|
|
72
|
+
const targetLevelId = targetParentId ?? "root";
|
|
73
|
+
const targetHasLevelsDoc = targetLevelId === "root" || (await levelsDocExists(client, levelsPath, targetLevelId));
|
|
74
|
+
const levelWrites = buildAppendLevelWrites(levelsPath, forest, targetParentId, targetHasLevelsDoc, map.header.isTodo);
|
|
75
|
+
return mergeWritesByPath([...writes, ...levelWrites]);
|
|
76
|
+
});
|
|
77
|
+
const path = formatPath(map.header.title, ancestorDetails(existingBodiesForPath, targetParentId));
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: JSON.stringify({ path, addedElementCount: createdIds.length }, null, 2),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getContext } from "../mcpContext.js";
|
|
3
|
+
import { newDocId } from "../domain/idGen.js";
|
|
4
|
+
export function registerCreateMap(server) {
|
|
5
|
+
server.tool("create_map", "新しいマインドマップ(マップ)を1つ作成する。" +
|
|
6
|
+
"list_mapsで確認して該当するマップが無いときだけ呼ぶこと。" +
|
|
7
|
+
"呼ぶ前に、会話でタイトル案をユーザーに提示し、明示的な確認を得ること" +
|
|
8
|
+
"(自動判断で勝手に作成しない)。", {
|
|
9
|
+
title: z.string().min(1).describe("マップのタイトル"),
|
|
10
|
+
isTodo: z.boolean().describe("TODOマップ(要素にチェックボックスを付ける)にするかどうか"),
|
|
11
|
+
}, async ({ title, isTodo }) => {
|
|
12
|
+
const { client, session } = getContext();
|
|
13
|
+
const existing = await client.listDocuments(`users/${session.uid}/headers`);
|
|
14
|
+
const maxOrder = existing.reduce((max, doc) => Math.max(max, Number(doc.data.order ?? 0)), 0);
|
|
15
|
+
// levels移行: 自前でIDを採番し、header本体と空のlevels/rootを1回の
|
|
16
|
+
// commitWritesにまとめて作る。levels/rootを最初から持たせておくことで、
|
|
17
|
+
// 直後に書き込み系ツールを呼んでも「アプリで一度開いてください」で
|
|
18
|
+
// 弾かれない(移行ではなく単なる初期化なので問題ない)。
|
|
19
|
+
// migratedはアプリの新規作成(header_add_model.dart)と揃える。無いと、
|
|
20
|
+
// 切替前のアプリが開いたときに未移行とみなしてbodiesから作り直してしまう。
|
|
21
|
+
const headerId = newDocId();
|
|
22
|
+
const headerPath = `users/${session.uid}/headers/${headerId}`;
|
|
23
|
+
await client.commitWrites([
|
|
24
|
+
{
|
|
25
|
+
path: headerPath,
|
|
26
|
+
fields: { title, order: maxOrder + 1, isTodo, editors: [], viewers: [] },
|
|
27
|
+
},
|
|
28
|
+
{ path: `${headerPath}/levels/root`, fields: { children: [], migrated: true } },
|
|
29
|
+
]);
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: JSON.stringify({ id: headerId, title, isTodo }, null, 2),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|