friday-mcp-v2 3.0.0 → 3.0.1

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.
Files changed (2) hide show
  1. package/dist/mcp-server.js +1070 -98
  2. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
11
11
  import { FridayWPClient, ConnectionRegistry } from "./wordpress-api.js";
12
12
  import { BridgeClient } from "./bridge-client.js";
13
13
  import fetch from "node-fetch";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { readFileSync, statSync, realpathSync } from "node:fs";
15
16
  import { execFile } from "node:child_process";
16
17
  import { resolve as resolvePath, sep, dirname } from "node:path";
@@ -102,6 +103,205 @@ const statusCache = {
102
103
  }
103
104
  };
104
105
 
106
+ // ========================================
107
+ // Snapshot キャッシュ(Phase 1: インデックスズレ対処)
108
+ // ========================================
109
+
110
+ /**
111
+ * djb2 ハッシュ → base36 文字列
112
+ * @param {string} str
113
+ * @returns {string}
114
+ */
115
+ function simpleHash(str) {
116
+ let hash = 5381;
117
+ for (let i = 0; i < str.length; i++) {
118
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
119
+ hash = hash & hash;
120
+ }
121
+ return (hash >>> 0).toString(36);
122
+ }
123
+
124
+ function generateSnapshotId() {
125
+ return 'snap_' + randomUUID().replace(/-/g, '').slice(0, 12);
126
+ }
127
+
128
+ function generateBlockRef(index) {
129
+ return 'r' + index;
130
+ }
131
+
132
+ /**
133
+ * ブロックの fingerprint を生成
134
+ * @param {object} block - state.allBlocks の要素
135
+ * @param {string} mode - "editor" | "headless"
136
+ * @returns {string}
137
+ */
138
+ function computeFingerprint(block, mode) {
139
+ const type = block.type || 'unknown';
140
+ let contentSource;
141
+ if (mode === 'headless' && block.html) {
142
+ contentSource = block.html.slice(0, 200);
143
+ } else if (block.attributes) {
144
+ contentSource = JSON.stringify(block.attributes).slice(0, 200);
145
+ } else {
146
+ contentSource = '';
147
+ }
148
+ return type + ':' + simpleHash(contentSource);
149
+ }
150
+
151
+ /**
152
+ * revision を計算(内容ベースハッシュ)
153
+ * serialize_blocks 相当の粒度で内容変更を検知する
154
+ * @param {{ allBlocks: Array }} state
155
+ * @param {string} mode
156
+ * @returns {string}
157
+ */
158
+ function computeRevision(state, mode) {
159
+ const source = state.allBlocks.map(b => {
160
+ const content = (mode === 'headless' && b.html)
161
+ ? b.html.slice(0, 200)
162
+ : JSON.stringify(b.attributes || {}).slice(0, 200);
163
+ return `${b.type}:${b.index}:${content}`;
164
+ }).join('|');
165
+ return 'rev_' + simpleHash(source);
166
+ }
167
+
168
+ /**
169
+ * 既に取得済みの currentState から snapshot を組み立て、キャッシュに登録して返す。
170
+ * buildResponseSnapshot() の「state を受け取る版」— getCurrentStructure() を呼ばない。
171
+ */
172
+ function buildSnapshotFromState(currentState, mode, postId, sessionId, siteName) {
173
+ if (!currentState?.allBlocks || currentState.allBlocks.length === 0) return null;
174
+
175
+ const newSnapshotId = generateSnapshotId();
176
+ const newSnapshotBlocks = buildSnapshotBlocks(currentState, mode);
177
+ const newRevision = computeRevision(currentState, mode);
178
+
179
+ const snapshotRecord = {
180
+ snapshotId: newSnapshotId, postId, mode,
181
+ sessionId: mode === 'editor' ? sessionId : null,
182
+ siteName: siteName || 'default',
183
+ createdAt: Date.now(),
184
+ revision: newRevision,
185
+ blocks: newSnapshotBlocks,
186
+ displayMode: 'full',
187
+ };
188
+ snapshotCache.set(snapshotRecord);
189
+
190
+ return {
191
+ snapshotId: newSnapshotId,
192
+ revision: newRevision,
193
+ blocks: newSnapshotBlocks.map(b => ({
194
+ ref: b.ref, index: b.index, type: b.type,
195
+ depth: b.depth, parentIndex: b.parentIndex,
196
+ })),
197
+ };
198
+ }
199
+
200
+ /**
201
+ * expectedRevision と現在の revision を比較する。
202
+ * 一致すれば null を返す。不一致の場合は最新 snapshot 付きのエラーレスポンスを返す。
203
+ * currentState から直接 snapshot を組み立てるため、追加の通信コストなし。
204
+ * @returns {object|null} null=一致(続行可)、object=不一致エラーレスポンス
205
+ */
206
+ function checkRevisionMismatch(expectedRevision, currentState, mode, postId, sessionId, siteName) {
207
+ const currentRevision = computeRevision(currentState, mode);
208
+ if (expectedRevision === currentRevision) return null;
209
+
210
+ // 不一致 — 更新は拒否するが、最新 snapshot を返して再取得の往復を省く
211
+ const latestSnapshot = buildSnapshotFromState(currentState, mode, postId, sessionId, siteName);
212
+ let text = `❌ REVISION_MISMATCH: 記事の構造が変更されています(expected: ${expectedRevision}, current: ${currentRevision})。\n` +
213
+ `更新はスキップしました。以下の最新 snapshot で再試行してください。`;
214
+ if (latestSnapshot) {
215
+ text = appendSnapshotToText(text, latestSnapshot);
216
+ }
217
+ return { content: [{ type: "text", text }], isError: true };
218
+ }
219
+
220
+ /**
221
+ * state.allBlocks から SnapshotBlock[] を構築
222
+ * @param {{ allBlocks: Array }} state
223
+ * @param {string} mode
224
+ * @returns {Array}
225
+ */
226
+ function buildSnapshotBlocks(state, mode) {
227
+ return state.allBlocks.map(b => ({
228
+ ref: generateBlockRef(b.index),
229
+ index: b.index,
230
+ type: b.type,
231
+ depth: b.depth || 0,
232
+ parentIndex: b.parentIndex ?? null,
233
+ fingerprint: computeFingerprint(b, mode),
234
+ section: b.section || null,
235
+ }));
236
+ }
237
+
238
+ const snapshotCache = {
239
+ /** @type {Map<string, object>} snapshotId → SnapshotRecord */
240
+ _entries: new Map(),
241
+ /** @type {Map<string, string[]>} "postId:siteName" → snapshotId[] (FIFO) */
242
+ _byArticle: new Map(),
243
+ TTL: 300_000,
244
+ MAX_PER_ARTICLE: 3,
245
+
246
+ /**
247
+ * snapshotId で取得(TTL チェック付き)
248
+ * @param {string} snapshotId
249
+ * @returns {object|null}
250
+ */
251
+ get(snapshotId) {
252
+ const entry = this._entries.get(snapshotId);
253
+ if (!entry) return null;
254
+ if ((Date.now() - entry.createdAt) >= this.TTL) {
255
+ this._remove(snapshotId);
256
+ return null;
257
+ }
258
+ return entry;
259
+ },
260
+
261
+ /**
262
+ * snapshot を登録(記事単位 eviction 付き)
263
+ * @param {object} record - SnapshotRecord
264
+ */
265
+ set(record) {
266
+ const articleKey = `${record.postId}:${record.siteName}`;
267
+ let articleSnaps = this._byArticle.get(articleKey) || [];
268
+ articleSnaps.push(record.snapshotId);
269
+ while (articleSnaps.length > this.MAX_PER_ARTICLE) {
270
+ const oldest = articleSnaps.shift();
271
+ this._entries.delete(oldest);
272
+ }
273
+ this._byArticle.set(articleKey, articleSnaps);
274
+ this._entries.set(record.snapshotId, record);
275
+ },
276
+
277
+ /** @param {string} snapshotId */
278
+ _remove(snapshotId) {
279
+ const entry = this._entries.get(snapshotId);
280
+ if (entry) {
281
+ const articleKey = `${entry.postId}:${entry.siteName}`;
282
+ const articleSnaps = this._byArticle.get(articleKey);
283
+ if (articleSnaps) {
284
+ const idx = articleSnaps.indexOf(snapshotId);
285
+ if (idx >= 0) articleSnaps.splice(idx, 1);
286
+ if (articleSnaps.length === 0) this._byArticle.delete(articleKey);
287
+ }
288
+ }
289
+ this._entries.delete(snapshotId);
290
+ },
291
+
292
+ /** @param {string} [siteName] 指定時はそのサイトのみ。省略時は全破棄。 */
293
+ clear(siteName) {
294
+ if (siteName) {
295
+ for (const [id, record] of this._entries) {
296
+ if (record.siteName === siteName) this._remove(id);
297
+ }
298
+ } else {
299
+ this._entries.clear();
300
+ this._byArticle.clear();
301
+ }
302
+ }
303
+ };
304
+
105
305
  // ヘルパー関数: 正規表現用に文字列をエスケープ
106
306
  function escapeRegExp(string) {
107
307
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -536,8 +736,17 @@ const targetSchema = {
536
736
  required: ["level", "contains"],
537
737
  description: "Select from heading to next same-level heading.",
538
738
  },
739
+ ref: {
740
+ type: "string",
741
+ description: "Block ref from snapshot (e.g. 'r5'). Requires snapshotId.",
742
+ },
743
+ refs: {
744
+ type: "array",
745
+ items: { type: "string" },
746
+ description: "Multiple block refs from snapshot. Requires snapshotId.",
747
+ },
539
748
  },
540
- description: "Target: one of selected/index/indices/range/heading + optional filters.",
749
+ description: "Target: one of selected/index/indices/range/heading/ref/refs + optional filters.",
541
750
  };
542
751
 
543
752
  const targetSchemaNoSelected = {
@@ -574,12 +783,16 @@ function normalizeTarget(target) {
574
783
  target.indices && 'indices',
575
784
  target.range && 'range',
576
785
  target.heading && 'heading',
786
+ target.ref && 'ref',
787
+ target.refs && 'refs',
577
788
  ].filter(Boolean);
578
789
  if (primaries.length > 1) {
579
790
  throw new Error(`target に複数の primary selector が指定されています: ${primaries.join(', ')}。1つだけ指定してください。`);
580
791
  }
581
792
 
582
793
  const result = {};
794
+ if (target.ref) result._ref = target.ref;
795
+ if (target.refs) result._refs = target.refs;
583
796
  if (target.selected) result.target = "selected";
584
797
  if (target.index !== undefined) result.index = target.index;
585
798
  if (target.indices) result.indices = target.indices;
@@ -628,6 +841,448 @@ function normalizeInsert(insert) {
628
841
  return { insertOnly: true, insertPosition: pos };
629
842
  }
630
843
 
844
+ // ========================================
845
+ // ref 解決ヘルパー(Phase 2a)
846
+ // ========================================
847
+
848
+ /**
849
+ * 現在のブロック構造を取得する(ref 解決・レスポンス snapshot 用)
850
+ * @param {string} mode - "editor" | "headless"
851
+ * @param {object} client - WordPressAPI インスタンス
852
+ * @param {number} postId
853
+ * @param {string|null} sessionId
854
+ * @returns {Promise<object>} { allBlocks: Block[] }
855
+ */
856
+ async function getCurrentStructure(mode, client, postId, sessionId, { safetyCritical = false } = {}) {
857
+ if (mode === 'editor') {
858
+ // Phase 3.1: editor からライブ状態を即時取得(session store キャッシュをバイパス)
859
+ try {
860
+ const result = await client.sendEditorCommand(
861
+ 'get_fresh_state', {}, 10000, postId, sessionId
862
+ );
863
+ if (result?.success && result.allBlocks) {
864
+ return result;
865
+ }
866
+ } catch (_e) {
867
+ // get_fresh_state 失敗
868
+ }
869
+ // safety-critical 経路: stale キャッシュにフォールバックしない
870
+ if (safetyCritical) {
871
+ throw new Error('editor のライブ状態を取得できませんでした。拡張機能の接続を確認してください。');
872
+ }
873
+ // 非 safety-critical: 従来キャッシュにフォールバック
874
+ return await client.getEditorState(postId, sessionId);
875
+ } else {
876
+ const structure = await client.headlessGetStructure(postId);
877
+ const blocksData = await client.headlessGetBlocks(postId);
878
+ structure.allBlocks = blocksData.blocks;
879
+ return structure;
880
+ }
881
+ }
882
+
883
+ /**
884
+ * snapshot の ref を現在構造上の index に解決する
885
+ * @param {string} snapshotId
886
+ * @param {string} ref
887
+ * @param {string} mode - 現在の mode
888
+ * @param {string|null} sessionId - 現在の sessionId
889
+ * @param {number} postId
890
+ * @param {object} currentState - getCurrentStructure() の結果(キャッシュ用)
891
+ * @returns {number} 解決された現在の index
892
+ * @throws {Error} 解決不能な場合
893
+ */
894
+ function resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState) {
895
+ // 1. snapshot 取得
896
+ const snap = snapshotCache.get(snapshotId);
897
+ if (!snap) {
898
+ throw new Error(`snapshot "${snapshotId}" が見つかりません(期限切れまたは無効)。get_article_structure を再取得してください。`);
899
+ }
900
+
901
+ // 2. 状態ソース束縛チェック
902
+ if (snap.mode !== mode) {
903
+ throw new Error(`snapshot は ${snap.mode} モードで取得されましたが、現在は ${mode} モードです。get_article_structure を再取得してください。`);
904
+ }
905
+ if (mode === 'editor' && snap.sessionId && sessionId && snap.sessionId !== sessionId) {
906
+ throw new Error(`snapshot は sessionId "${snap.sessionId}" で取得されましたが、現在の sessionId は "${sessionId}" です。get_article_structure を再取得してください。`);
907
+ }
908
+ if (snap.postId !== postId) {
909
+ throw new Error(`snapshot は postId ${snap.postId} 用ですが、postId ${postId} に対して使用されています。`);
910
+ }
911
+
912
+ // 3. snapshot 内の ref 情報を取得
913
+ const snapBlock = snap.blocks.find(b => b.ref === ref);
914
+ if (!snapBlock) {
915
+ throw new Error(`ref "${ref}" は snapshot "${snapshotId}" 内に存在しません。`);
916
+ }
917
+
918
+ // 4. 現在構造の検証
919
+ const currentBlocks = currentState?.allBlocks;
920
+ if (!currentBlocks || currentBlocks.length === 0) {
921
+ throw new Error('現在のブロック構造を取得できませんでした。');
922
+ }
923
+
924
+ // 5. fingerprint 照合(2段、曖昧ならエラー)
925
+ const candidates = [];
926
+ for (const cb of currentBlocks) {
927
+ const fp = computeFingerprint(cb, mode);
928
+ if (fp === snapBlock.fingerprint) {
929
+ candidates.push(cb);
930
+ }
931
+ }
932
+
933
+ // 5a. 完全一致 1件 → 確定
934
+ if (candidates.length === 1) return candidates[0].index;
935
+
936
+ // 5b. 完全一致 複数件 → depth + parentIndex で絞り込み
937
+ if (candidates.length > 1) {
938
+ const refined = candidates.filter(c =>
939
+ (c.depth || 0) === snapBlock.depth &&
940
+ (c.parentIndex ?? null) === snapBlock.parentIndex
941
+ );
942
+ if (refined.length === 1) return refined[0].index;
943
+ throw new Error(
944
+ `ref "${ref}" (type: ${snapBlock.type}) の解決先が ${refined.length > 0 ? refined.length : candidates.length} 件見つかりました。` +
945
+ `一意に特定できません。get_article_structure を再取得してください。`
946
+ );
947
+ }
948
+
949
+ // 5c. 一致 0件 → エラー
950
+ throw new Error(
951
+ `ref "${ref}" (${snapBlock.type}, fingerprint: ${snapBlock.fingerprint}) に一致するブロックが見つかりません。` +
952
+ `構造が大幅に変更された可能性があります。get_article_structure を再取得してください。`
953
+ );
954
+ }
955
+
956
+ /**
957
+ * safety-critical な fresh state 取得 + expectedRevision チェック。
958
+ * state 取得と revision 判定の入口を統一し、分岐ごとのズレを防ぐ。
959
+ * @returns {{ currentState, error? }}
960
+ */
961
+ async function acquireFreshState({
962
+ expectedRevision, mode, client, postId, sessionId, siteName,
963
+ }) {
964
+ let currentState;
965
+ try {
966
+ currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
967
+ } catch (e) {
968
+ return { error: {
969
+ content: [{ type: "text", text: `❌ 構造取得に失敗しました: ${e.message}${expectedRevision ? '\nexpectedRevision が指定されているため、更新を中止します。' : ''}` }],
970
+ isError: true,
971
+ }};
972
+ }
973
+ if (expectedRevision) {
974
+ const mismatch = checkRevisionMismatch(expectedRevision, currentState, mode, postId, sessionId, siteName);
975
+ if (mismatch) return { error: mismatch };
976
+ }
977
+ return { currentState };
978
+ }
979
+
980
+ /**
981
+ * ref/refs → index/indices 解決 + expectedRevision チェックの共通処理。
982
+ * delete_block / duplicate_block / update_blocks / table_operations で共通利用する。
983
+ * @returns {{ index?, indices?, error? }}
984
+ * - 成功: { index } or { indices } or {}(ref なしで revision OK の場合)
985
+ * - 失敗: { error: MCP エラーレスポンス }(呼び出し元は return error で即返却)
986
+ */
987
+ async function resolveRefsAndCheckRevision({
988
+ snapshotId, ref, refs, expectedRevision,
989
+ mode, client, postId, sessionId, siteName,
990
+ }) {
991
+ const hasRef = ref || refs;
992
+
993
+ // Case 1: ref/refs 指定あり
994
+ if (hasRef) {
995
+ if (!snapshotId) {
996
+ return { error: {
997
+ content: [{ type: "text", text: "❌ ref/refs を使用するには snapshotId が必要です。get_article_structure で取得してください。" }],
998
+ isError: true,
999
+ }};
1000
+ }
1001
+ const { currentState, error } = await acquireFreshState({
1002
+ expectedRevision, mode, client, postId, sessionId, siteName,
1003
+ });
1004
+ if (error) return { error };
1005
+ try {
1006
+ if (ref) {
1007
+ const index = resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState);
1008
+ return { index };
1009
+ }
1010
+ if (refs) {
1011
+ const indices = refs.map(r => resolveRefFromState(snapshotId, r, mode, sessionId, postId, currentState));
1012
+ return { indices };
1013
+ }
1014
+ } catch (e) {
1015
+ return { error: {
1016
+ content: [{ type: "text", text: `❌ ref 解決エラー: ${e.message}` }],
1017
+ isError: true,
1018
+ }};
1019
+ }
1020
+ }
1021
+
1022
+ // Case 2: ref なし + expectedRevision のみ
1023
+ if (expectedRevision) {
1024
+ const { error } = await acquireFreshState({
1025
+ expectedRevision, mode, client, postId, sessionId, siteName,
1026
+ });
1027
+ if (error) return { error };
1028
+ }
1029
+
1030
+ // Case 3: どちらもなし
1031
+ return {};
1032
+ }
1033
+
1034
+ /**
1035
+ * 更新後のレスポンス snapshot を構築する
1036
+ * @param {string} mode
1037
+ * @param {object} client
1038
+ * @param {number} postId
1039
+ * @param {string|null} sessionId
1040
+ * @param {string} siteName
1041
+ * @returns {Promise<object|null>} { snapshotId, revision, blocks[] } or null
1042
+ */
1043
+ async function buildResponseSnapshot(mode, client, postId, sessionId, siteName) {
1044
+ let newState;
1045
+ try {
1046
+ newState = await getCurrentStructure(mode, client, postId, sessionId);
1047
+ } catch {
1048
+ return null;
1049
+ }
1050
+
1051
+ if (!newState?.allBlocks || newState.allBlocks.length === 0) {
1052
+ return null;
1053
+ }
1054
+
1055
+ const newSnapshotId = generateSnapshotId();
1056
+ const newSnapshotBlocks = buildSnapshotBlocks(newState, mode);
1057
+ const newRevision = computeRevision(newState, mode);
1058
+
1059
+ const snapshotRecord = {
1060
+ snapshotId: newSnapshotId,
1061
+ postId,
1062
+ mode,
1063
+ sessionId: mode === 'editor' ? sessionId : null,
1064
+ siteName: siteName || 'default',
1065
+ createdAt: Date.now(),
1066
+ revision: newRevision,
1067
+ blocks: newSnapshotBlocks,
1068
+ displayMode: 'full',
1069
+ };
1070
+ snapshotCache.set(snapshotRecord);
1071
+
1072
+ return {
1073
+ snapshotId: newSnapshotId,
1074
+ revision: newRevision,
1075
+ blocks: newSnapshotBlocks.map(b => ({
1076
+ ref: b.ref,
1077
+ index: b.index,
1078
+ type: b.type,
1079
+ depth: b.depth,
1080
+ parentIndex: b.parentIndex,
1081
+ })),
1082
+ };
1083
+ }
1084
+
1085
+ /**
1086
+ * snapshot 情報をテキストレスポンスに付加する
1087
+ * blocks[] を含めて AI が新しい ref を取得できるようにする
1088
+ * @param {string} text - 元のレスポンステキスト
1089
+ * @param {object|null} snapshot - buildResponseSnapshot の結果
1090
+ * @param {object} [refInfo] - 単体操作の ref 解決情報(1→N 展開追跡用)
1091
+ * @param {string} [refInfo.oldSnapshotId] - 元の snapshotId
1092
+ * @param {string} [refInfo.usedRef] - 使用した ref
1093
+ * @param {number} [refInfo.resolvedIndex] - 解決された元の index
1094
+ * @returns {string} snapshot 行と blocks 一覧が付加されたテキスト
1095
+ */
1096
+ function appendSnapshotToText(text, snapshot, refInfo) {
1097
+ if (!snapshot) return text;
1098
+
1099
+ // 1→N 展開チェック(単体操作時のみ)
1100
+ let expandedLine = '';
1101
+ if (refInfo?.oldSnapshotId && refInfo?.usedRef) {
1102
+ const oldSnap = snapshotCache.get(refInfo.oldSnapshotId);
1103
+ if (oldSnap) {
1104
+ const oldCount = oldSnap.blocks.length;
1105
+ const newCount = snapshot.blocks.length;
1106
+ const delta = newCount - oldCount;
1107
+ if (delta > 0) {
1108
+ // 単体操作での展開: 元 index 位置から delta+1 個が新ブロック
1109
+ const newRefs = snapshot.blocks
1110
+ .filter(b => b.index >= refInfo.resolvedIndex && b.index < refInfo.resolvedIndex + 1 + delta)
1111
+ .map(b => b.ref);
1112
+ expandedLine = `\nexpanded: ${refInfo.usedRef} → [${newRefs.join(', ')}]`;
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ let out = text + `\n\n[snapshot:${snapshot.snapshotId} rev:${snapshot.revision}]${expandedLine}`;
1118
+ if (snapshot.blocks && snapshot.blocks.length > 0) {
1119
+ out += '\nblocks:';
1120
+ for (const b of snapshot.blocks) {
1121
+ const parent = b.parentIndex !== null ? `,p:${b.parentIndex}` : '';
1122
+ out += `\n [${b.index}|${b.ref}] ${b.type} (d:${b.depth}${parent})`;
1123
+ }
1124
+ }
1125
+ return out;
1126
+ }
1127
+
1128
+ /**
1129
+ * batch operations の実行
1130
+ * @param {Array} operations - 各 operation: { target, newHTML?, replacements?, attributeUpdates? }
1131
+ * @param {string} snapshotId
1132
+ * @param {string} mode
1133
+ * @param {object} client
1134
+ * @param {number} postId
1135
+ * @param {string|null} sessionId
1136
+ * @param {string} siteName
1137
+ * @param {string} _modeTag
1138
+ * @param {string} toolName
1139
+ */
1140
+ async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision) {
1141
+ // 1. 全 operation の target を正規化 & ref を一括解決
1142
+ let currentState;
1143
+ try {
1144
+ currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
1145
+ } catch (e) {
1146
+ return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
1147
+ }
1148
+
1149
+ // revision 楽観的ロック(expectedRevision が指定されている場合のみ)
1150
+ if (expectedRevision) {
1151
+ const mismatch = checkRevisionMismatch(
1152
+ expectedRevision, currentState, mode, postId, sessionId, siteName
1153
+ );
1154
+ if (mismatch) return mismatch;
1155
+ }
1156
+
1157
+ // batch は ref のみ許可(1 operation = 1 ref = 1 block)
1158
+ const resolvedOps = [];
1159
+ try {
1160
+ for (let i = 0; i < operations.length; i++) {
1161
+ const op = operations[i];
1162
+ const ref = op.target?.ref;
1163
+ if (!ref || typeof ref !== 'string') {
1164
+ return {
1165
+ content: [{ type: "text", text: `❌ operations[${i}]: batch の target には ref(文字列)が必須です。index/section/range 等は batch では使用できません。` }],
1166
+ isError: true,
1167
+ };
1168
+ }
1169
+ // ref 以外の selector が指定されていないか確認
1170
+ const extraKeys = Object.keys(op.target).filter(k => k !== 'ref');
1171
+ if (extraKeys.length > 0) {
1172
+ return {
1173
+ content: [{ type: "text", text: `❌ operations[${i}]: batch の target は ref のみ指定可能です。不正なキー: ${extraKeys.join(', ')}` }],
1174
+ isError: true,
1175
+ };
1176
+ }
1177
+
1178
+ // 変更種別がちょうど1つか検証(1 op = 1 ref = 1 change-kind)
1179
+ const changeKinds = [
1180
+ op.replacements && op.replacements.length > 0 && 'replacements',
1181
+ op.newHTML && 'newHTML',
1182
+ op.attributeUpdates && 'attributeUpdates',
1183
+ ].filter(Boolean);
1184
+ if (changeKinds.length === 0) {
1185
+ return {
1186
+ content: [{ type: "text", text: `❌ operations[${i}]: 変更内容が指定されていません。replacements, newHTML, attributeUpdates のいずれか1つを指定してください。` }],
1187
+ isError: true,
1188
+ };
1189
+ }
1190
+ if (changeKinds.length > 1) {
1191
+ return {
1192
+ content: [{ type: "text", text: `❌ operations[${i}]: 複数の変更種別が指定されています: ${changeKinds.join(', ')}。1つだけ指定してください。` }],
1193
+ isError: true,
1194
+ };
1195
+ }
1196
+
1197
+ const resolvedIndex = resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState);
1198
+
1199
+ resolvedOps.push({
1200
+ opIndex: i,
1201
+ ref,
1202
+ resolvedIndex,
1203
+ newHTML: op.newHTML,
1204
+ replacements: op.replacements,
1205
+ attributeUpdates: op.attributeUpdates,
1206
+ });
1207
+ }
1208
+ } catch (e) {
1209
+ return { content: [{ type: "text", text: `❌ batch ref 解決エラー: ${e.message}` }], isError: true };
1210
+ }
1211
+
1212
+ // 同一 index への複数 operation を排他チェック
1213
+ const indexSet = new Set();
1214
+ for (const op of resolvedOps) {
1215
+ if (indexSet.has(op.resolvedIndex)) {
1216
+ return {
1217
+ content: [{ type: "text", text: `❌ index ${op.resolvedIndex} (ref: ${op.ref}) に対して複数の operation が指定されています。同一ブロックへの複数操作は許可されていません。` }],
1218
+ isError: true,
1219
+ };
1220
+ }
1221
+ indexSet.add(op.resolvedIndex);
1222
+ }
1223
+
1224
+ // 2. 降順ソート(index が大きい方から処理 — 1 op = 1 index なので安全)
1225
+ resolvedOps.sort((a, b) => b.resolvedIndex - a.resolvedIndex);
1226
+
1227
+ // 3. 各 operation を順次実行
1228
+ const results = [];
1229
+ for (const op of resolvedOps) {
1230
+ const index = op.resolvedIndex;
1231
+
1232
+ let opResult;
1233
+ try {
1234
+ // batch は ref → index 解決済み。1 op = 1 index で下流に渡す
1235
+ const downstreamParams = {
1236
+ index,
1237
+ replacements: op.replacements,
1238
+ newHTML: op.newHTML,
1239
+ attributeUpdates: op.attributeUpdates,
1240
+ insertOnly: false,
1241
+ dryRun: false,
1242
+ _fromInsertBlock: false,
1243
+ };
1244
+ if (mode === 'headless') {
1245
+ opResult = await client.headlessUpdate(postId, downstreamParams);
1246
+ } else {
1247
+ opResult = await client.sendEditorCommand("update_blocks", downstreamParams, 10000, postId, sessionId);
1248
+ }
1249
+
1250
+ results.push({
1251
+ ref: op.ref,
1252
+ resolvedIndex: op.resolvedIndex,
1253
+ status: (opResult && opResult.success !== false && opResult.ok !== false) ? 'updated' : 'failed',
1254
+ error: opResult?.error || null,
1255
+ });
1256
+ } catch (e) {
1257
+ results.push({
1258
+ ref: op.ref,
1259
+ resolvedIndex: op.resolvedIndex,
1260
+ status: 'failed',
1261
+ error: e.message,
1262
+ });
1263
+ }
1264
+ }
1265
+
1266
+ // 4. レスポンス snapshot 構築
1267
+ const snapshot = await buildResponseSnapshot(mode, client, postId, sessionId, siteName);
1268
+
1269
+ // レスポンス組み立て
1270
+ // 注: 1→N 展開の newRefs[] は snapshot.blocks[] 全体を返しているため、
1271
+ // AI は新 snapshot から直接新しい ref を特定できる。
1272
+ // per-operation の newRefs 追跡は複数変更時に不正確になるため省略。
1273
+ const successCount = results.filter(r => r.status !== 'failed').length;
1274
+ const failCount = results.filter(r => r.status === 'failed').length;
1275
+ let text = `✅ batch 完了\n\n成功: ${successCount} / 失敗: ${failCount}`;
1276
+ for (const r of results) {
1277
+ const refLabel = r.ref ? ` (${r.ref})` : '';
1278
+ text += `\n [${r.resolvedIndex}]${refLabel}: ${r.status}`;
1279
+ if (r.error) text += ` — ${r.error}`;
1280
+ }
1281
+ text = appendSnapshotToText(text + _modeTag, snapshot);
1282
+
1283
+ return { content: [{ type: "text", text }] };
1284
+ }
1285
+
631
1286
  // フィードバック送信ヘルパー
632
1287
  const FEEDBACK_URL = process.env.FRIDAY_FEEDBACK_URL || '';
633
1288
  async function sendFeedback(data) {
@@ -776,7 +1431,7 @@ const tools = [
776
1431
  },
777
1432
  {
778
1433
  name: "delete_block",
779
- description: "Delete block(s) by index or selection.",
1434
+ description: "Delete block(s) by index, ref, or selection.",
780
1435
  inputSchema: {
781
1436
  type: "object",
782
1437
  properties: {
@@ -785,12 +1440,16 @@ const tools = [
785
1440
  index: { type: "number", description: "Index (0-based). Omit for selected." },
786
1441
  count: { type: "number", description: "Consecutive count (default: 1)" },
787
1442
  indices: { type: "array", items: { type: "number" }, description: "Multiple indices (exclusive with index/count)" },
1443
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref/refs." },
1444
+ ref: { type: "string", description: "Block ref from snapshot (exclusive with index/indices)." },
1445
+ refs: { type: "array", items: { type: "string" }, description: "Multiple block refs (exclusive with index/indices)." },
1446
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
788
1447
  },
789
1448
  },
790
1449
  },
791
1450
  {
792
1451
  name: "move_block",
793
- description: "Move block(s). Use from/to (top-level) or fromFlat/toFlat (nested).",
1452
+ description: "Move block(s). Use from/to (top-level), fromFlat/toFlat (nested), or fromRef+beforeRef/afterRef (ref-based).",
794
1453
  inputSchema: {
795
1454
  type: "object",
796
1455
  properties: {
@@ -801,6 +1460,11 @@ const tools = [
801
1460
  fromFlat: { type: "number", description: "Source flattened index" },
802
1461
  toFlat: { type: "number", description: "Target flattened position" },
803
1462
  count: { type: "integer", description: "Consecutive count (default: 1)" },
1463
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
1464
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
1465
+ fromRef: { type: "string", description: "Source block ref (exclusive with from/fromFlat)." },
1466
+ beforeRef: { type: "string", description: "Move before this ref (exclusive with to/toFlat/afterRef)." },
1467
+ afterRef: { type: "string", description: "Move after this ref (exclusive with to/toFlat/beforeRef)." },
804
1468
  },
805
1469
  },
806
1470
  },
@@ -830,13 +1494,16 @@ const tools = [
830
1494
  },
831
1495
  {
832
1496
  name: "duplicate_block",
833
- description: "Duplicate block by index or selection.",
1497
+ description: "Duplicate block by index, ref, or selection.",
834
1498
  inputSchema: {
835
1499
  type: "object",
836
1500
  properties: {
837
1501
  postId: postIdParam,
838
1502
  site: siteParam,
839
1503
  index: { type: "number", description: "Index (0-based). Omit for selected." },
1504
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
1505
+ ref: { type: "string", description: "Block ref from snapshot (exclusive with index)." },
1506
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
840
1507
  },
841
1508
  },
842
1509
  },
@@ -919,7 +1586,7 @@ const tools = [
919
1586
  },
920
1587
  {
921
1588
  name: "insert_block",
922
- description: "Insert Gutenberg HTML. Omit index to append.",
1589
+ description: "Insert Gutenberg HTML. Omit index to append. Use beforeRef/afterRef for ref-based positioning.",
923
1590
  inputSchema: {
924
1591
  type: "object",
925
1592
  properties: {
@@ -928,6 +1595,11 @@ const tools = [
928
1595
  rawHTML: { type: "string", description: "HTML to insert (exclusive with filePath)" },
929
1596
  filePath: { type: "string", description: "Local file path (exclusive with rawHTML)" },
930
1597
  index: { type: "number", description: "Position (0-based). Omit to append." },
1598
+ position: { type: "string", enum: ["before", "after"], description: "Insert relative to index: 'before' (default) or 'after'. Requires index." },
1599
+ snapshotId: { type: "string", description: "Snapshot ID. Required when using beforeRef/afterRef." },
1600
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
1601
+ beforeRef: { type: "string", description: "Insert before this ref (exclusive with index)." },
1602
+ afterRef: { type: "string", description: "Insert after this ref (exclusive with index)." },
931
1603
  },
932
1604
  },
933
1605
  },
@@ -957,13 +1629,59 @@ const tools = [
957
1629
  },
958
1630
  {
959
1631
  name: "update_blocks",
960
- description: "Update block(s). Supports replacements, newHTML, or attributeUpdates.",
1632
+ description: "Update block(s). Supports replacements, newHTML, or attributeUpdates. Use ref+snapshotId for safe index resolution.",
961
1633
  inputSchema: {
962
1634
  type: "object",
963
1635
  properties: {
964
1636
  postId: postIdParam,
965
1637
  site: siteParam,
1638
+ snapshotId: {
1639
+ type: "string",
1640
+ description: "Snapshot ID from get_article_structure. Required when using ref/refs in target.",
1641
+ },
1642
+ expectedRevision: {
1643
+ type: "string",
1644
+ description: "Revision from snapshot. If provided, rejects update when structure has changed.",
1645
+ },
966
1646
  target: targetSchema,
1647
+ operations: {
1648
+ type: "array",
1649
+ items: {
1650
+ type: "object",
1651
+ properties: {
1652
+ target: {
1653
+ type: "object",
1654
+ properties: {
1655
+ ref: { type: "string", description: "Block ref from snapshot (required)." },
1656
+ },
1657
+ required: ["ref"],
1658
+ description: "Batch target: ref only (1 block per operation).",
1659
+ },
1660
+ replacements: {
1661
+ type: "array",
1662
+ items: {
1663
+ type: "object",
1664
+ properties: {
1665
+ old: { type: "string" },
1666
+ new: { type: "string" },
1667
+ regex: { type: "boolean" },
1668
+ },
1669
+ required: ["old", "new"],
1670
+ },
1671
+ },
1672
+ newHTML: { type: "string" },
1673
+ attributeUpdates: {
1674
+ type: "object",
1675
+ properties: {
1676
+ filter: { type: "object" },
1677
+ set: { type: "object" },
1678
+ },
1679
+ },
1680
+ },
1681
+ required: ["target"],
1682
+ },
1683
+ description: "Batch operations: each targets a single block by ref. Requires snapshotId.",
1684
+ },
967
1685
  replacements: {
968
1686
  type: "array",
969
1687
  items: {
@@ -999,12 +1717,12 @@ const tools = [
999
1717
  description: "Preview only",
1000
1718
  },
1001
1719
  },
1002
- required: ["target"],
1720
+ // target は operations[] 使用時は不要
1003
1721
  },
1004
1722
  },
1005
1723
  {
1006
1724
  name: "table_operations",
1007
- description: "Table operations (get/update/add/delete rows/columns/cells).",
1725
+ description: "Table operations (get/update/add/delete rows/columns/cells). Use ref+snapshotId for safe index resolution.",
1008
1726
  inputSchema: {
1009
1727
  type: "object",
1010
1728
  properties: {
@@ -1040,8 +1758,11 @@ const tools = [
1040
1758
  items: { type: "string" },
1041
1759
  description: "add_row: new cells, add_column: init values, update_row/column: replacements. Omit for empty.",
1042
1760
  },
1761
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
1762
+ ref: { type: "string", description: "Block ref from snapshot (exclusive with index)." },
1763
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
1043
1764
  },
1044
- required: ["index", "action"],
1765
+ required: ["action"],
1045
1766
  },
1046
1767
  },
1047
1768
  {
@@ -1114,11 +1835,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1114
1835
  */
1115
1836
  async function handleUpdateBlocksTool(args, toolName) {
1116
1837
  const name = toolName || "update_blocks";
1117
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
1838
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
1118
1839
  if (mode === 'error') {
1119
1840
  return errorResponse(name, message, args?.site);
1120
1841
  }
1121
1842
  const _modeTag = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
1843
+ const _siteName = siteName || args?.site || 'default';
1122
1844
 
1123
1845
  // slug → postId 解決(headless モード時のみ)
1124
1846
  const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
@@ -1140,6 +1862,27 @@ async function handleUpdateBlocksTool(args, toolName) {
1140
1862
  };
1141
1863
  }
1142
1864
 
1865
+ // --- operations[] 排他チェック ---
1866
+ const snapshotId = args?.snapshotId;
1867
+ if (args?.operations && args.operations.length > 0) {
1868
+ const conflicting = ['target', 'replacements', 'newHTML', 'attributeUpdates', 'insert', 'filePath']
1869
+ .filter(k => args[k] !== undefined);
1870
+ if (conflicting.length > 0) {
1871
+ return {
1872
+ content: [{ type: "text", text: `❌ operations と ${conflicting.join(', ')} は同時に指定できません。operations を使う場合、各操作は operations 内に記述してください。` }],
1873
+ isError: true,
1874
+ };
1875
+ }
1876
+ if (!snapshotId) {
1877
+ return {
1878
+ content: [{ type: "text", text: `❌ operations には snapshotId が必要です。get_article_structure で取得してください。` }],
1879
+ isError: true,
1880
+ };
1881
+ }
1882
+ // batch 実行に委譲
1883
+ return await handleBatchOperations(args.operations, snapshotId, mode, client, postId, _sessionId, _siteName, _modeTag, name, args?.expectedRevision);
1884
+ }
1885
+
1143
1886
  // --- normalizeTarget / normalizeInsert ---
1144
1887
  let tp, ins;
1145
1888
  try {
@@ -1149,6 +1892,31 @@ async function handleUpdateBlocksTool(args, toolName) {
1149
1892
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
1150
1893
  }
1151
1894
 
1895
+ // --- ref 解決 + revision チェック(共通ヘルパー) ---
1896
+ const resolved = await resolveRefsAndCheckRevision({
1897
+ snapshotId,
1898
+ ref: tp._ref,
1899
+ refs: tp._refs,
1900
+ expectedRevision: args?.expectedRevision,
1901
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
1902
+ });
1903
+ if (resolved.error) return resolved.error;
1904
+ if (resolved.index !== undefined) {
1905
+ tp.index = resolved.index;
1906
+ delete tp._ref;
1907
+ }
1908
+ if (resolved.indices !== undefined) {
1909
+ tp.indices = resolved.indices;
1910
+ delete tp._refs;
1911
+ }
1912
+
1913
+ // 1→N 展開追跡用の refInfo(単体 ref + newHTML 置換時のみ。insert 系は対象外)
1914
+ const _refInfo = (snapshotId && args?.target?.ref && args?.newHTML && !args?.insert) ? {
1915
+ oldSnapshotId: snapshotId,
1916
+ usedRef: args.target.ref,
1917
+ resolvedIndex: tp.index,
1918
+ } : null;
1919
+
1152
1920
  const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex,
1153
1921
  contains, headingLevel, headingContains } = tp;
1154
1922
  const { insertOnly, insertPosition } = ins;
@@ -1156,6 +1924,11 @@ async function handleUpdateBlocksTool(args, toolName) {
1156
1924
  _fromInsertBlock, appendToEnd } = (args || {});
1157
1925
  let { newHTML } = (args || {});
1158
1926
 
1927
+ // [INSERT_OBSERVE] Step 5: insert_block 経由時の送信パラメータログ
1928
+ if (process.env.FRIDAY_DEBUG === '1' && _fromInsertBlock) {
1929
+ console.log(`[INSERT_OBSERVE] mcp-send: index=${index}, postId=${postId}, sessionId=${_sessionId || 'none'}, mode=${mode}, appendToEnd=${appendToEnd}, htmlLen=${(newHTML || '').length}`);
1930
+ }
1931
+
1159
1932
  // --- filePath → newHTML 解決(update_blocks 直接呼び出し時) ---
1160
1933
  if (args?.filePath) {
1161
1934
  if (newHTML) {
@@ -1196,7 +1969,7 @@ async function handleUpdateBlocksTool(args, toolName) {
1196
1969
  };
1197
1970
  }
1198
1971
 
1199
- // --- ターゲットチェック(appendToEnd 時は不要) ---
1972
+ // --- ターゲットチェック(appendToEnd / _parentIndex 時は不要) ---
1200
1973
  const hasTarget = target || index !== undefined || indices ||
1201
1974
  startIndex !== undefined || section || blockType || contains ||
1202
1975
  (headingLevel && headingContains);
@@ -1238,7 +2011,8 @@ async function handleUpdateBlocksTool(args, toolName) {
1238
2011
  _fromInsertBlock: _fromInsertBlock || false,
1239
2012
  });
1240
2013
  const count = result.results?.[0]?.count || 1;
1241
- return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}` }] };
2014
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2015
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1242
2016
  }
1243
2017
 
1244
2018
  const result = await client.headlessUpdate(postId, {
@@ -1263,13 +2037,14 @@ async function handleUpdateBlocksTool(args, toolName) {
1263
2037
  return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText}${deprecationWarning}` }] };
1264
2038
  }
1265
2039
 
2040
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
1266
2041
  if (result.results) {
1267
2042
  const successCount = result.results.filter(r => r.success).length;
1268
2043
  const failCount = result.results.filter(r => !r.success).length;
1269
- return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}` }] };
2044
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}`, _snap, _refInfo) }] };
1270
2045
  }
1271
2046
 
1272
- return { content: [{ type: "text", text: `✅ 更新完了${deprecationWarning}${_modeTag}` }] };
2047
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ 更新完了${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1273
2048
  } catch (e) {
1274
2049
  const formatted = formatHeadlessConflictError(e);
1275
2050
  if (formatted) return formatted;
@@ -1291,7 +2066,8 @@ async function handleUpdateBlocksTool(args, toolName) {
1291
2066
  if (!result || result.timeout) return timeoutResponse(name, client, args?.site);
1292
2067
  if (!result.success) return errorResponse(name, result.error, args?.site);
1293
2068
  const count = result.inserted || 1;
1294
- return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}` }] };
2069
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2070
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1295
2071
  }
1296
2072
 
1297
2073
  // target: "selected" の場合 → エディタ状態から選択情報を取得してMCP側で処理
@@ -1344,7 +2120,10 @@ async function handleUpdateBlocksTool(args, toolName) {
1344
2120
  return timeoutResponse(name, client, args?.site);
1345
2121
  if (!result.success)
1346
2122
  return errorResponse(name, result.error, args?.site);
1347
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}${deprecationWarning}` }] };
2123
+ {
2124
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2125
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}${deprecationWarning}`, _snap, _refInfo) }] };
2126
+ }
1348
2127
  }
1349
2128
 
1350
2129
  // newHTMLの場合
@@ -1360,7 +2139,10 @@ async function handleUpdateBlocksTool(args, toolName) {
1360
2139
  return timeoutResponse(name, client, args?.site);
1361
2140
  if (!result.success)
1362
2141
  return errorResponse(name, result.error, args?.site);
1363
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}` }] };
2142
+ {
2143
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2144
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}`, _snap, _refInfo) }] };
2145
+ }
1364
2146
  }
1365
2147
 
1366
2148
  // attributeUpdatesの場合
@@ -1374,12 +2156,15 @@ async function handleUpdateBlocksTool(args, toolName) {
1374
2156
  return timeoutResponse(name, client, args?.site);
1375
2157
  if (!result.success)
1376
2158
  return errorResponse(name, result.error, args?.site);
1377
- return { content: [{ type: "text", text: `✅ 属性更新完了${deprecationWarning}` }] };
2159
+ {
2160
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2161
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ 属性更新完了${deprecationWarning}`, _snap, _refInfo) }] };
2162
+ }
1378
2163
  }
1379
2164
 
1380
2165
  // その他のターゲット → 全てWP側で解決&実行
1381
2166
  const maxWait = (section || blockType || contains) ? 15000 : 10000;
1382
- const result = await client.sendEditorCommand("update_blocks", {
2167
+ const editorParams = {
1383
2168
  index, indices, startIndex, endIndex,
1384
2169
  section, blockType, typeIndex, contains,
1385
2170
  headingLevel, headingContains,
@@ -1388,7 +2173,8 @@ async function handleUpdateBlocksTool(args, toolName) {
1388
2173
  insertPosition,
1389
2174
  dryRun: dryRun || false,
1390
2175
  _fromInsertBlock: _fromInsertBlock || false,
1391
- }, maxWait, _postId, _sessionId);
2176
+ };
2177
+ const result = await client.sendEditorCommand("update_blocks", editorParams, maxWait, _postId, _sessionId);
1392
2178
  if (!result || result.timeout)
1393
2179
  return timeoutResponse(name, client, args?.site);
1394
2180
  if (!result.success)
@@ -1412,15 +2198,16 @@ async function handleUpdateBlocksTool(args, toolName) {
1412
2198
  return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText || "(対象なし)"}${deprecationWarning}` }] };
1413
2199
  }
1414
2200
 
2201
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
1415
2202
  if (result.results) {
1416
2203
  const successCount = result.results.filter(r => r.success && !r.skipped).length;
1417
2204
  const skipCount = result.results.filter(r => r.skipped).length;
1418
2205
  const failCount = result.results.filter(r => !r.success).length;
1419
2206
  const detailText = result.results.filter(r => r.success && r.replaceCount > 0).map(r => ` [${r.index}]: ${r.replaceCount}箇所`).join('\n');
1420
- return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}${_modeTag}` }] };
2207
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1421
2208
  }
1422
2209
 
1423
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}${_modeTag}` }] };
2210
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1424
2211
  }
1425
2212
 
1426
2213
  // ツール実行のハンドラ
@@ -1475,6 +2262,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1475
2262
  state = await client.getEditorState(_postId, _sessionId);
1476
2263
  }
1477
2264
 
2265
+ // --- snapshot 作成(allBlocks がある場合のみ) ---
2266
+ let _refTag = (_idx, _prefix) => '';
2267
+ let _snapshotLine = '';
2268
+
2269
+ if (state.allBlocks && state.allBlocks.length > 0) {
2270
+ const snapshotId = generateSnapshotId();
2271
+ const snapshotBlocks = buildSnapshotBlocks(state, mode);
2272
+ const revision = computeRevision(state, mode);
2273
+ const snapshotRecord = {
2274
+ snapshotId, postId, mode,
2275
+ sessionId: mode === 'editor' ? _sessionId : null,
2276
+ siteName: siteName || 'default',
2277
+ createdAt: Date.now(),
2278
+ revision,
2279
+ blocks: snapshotBlocks,
2280
+ displayMode: full ? 'full' : contains ? 'contains' : section ? (blockType ? 'section+blockType' : 'section') : blockType ? 'blockType' : headingLevel ? 'headingLevel' : 'default',
2281
+ };
2282
+ snapshotCache.set(snapshotRecord);
2283
+
2284
+ const _refMap = new Map(snapshotBlocks.map(b => [b.index, b.ref]));
2285
+ _refTag = (idx, prefix = '|') => { const r = _refMap.get(idx); return r ? `${prefix}${r}` : ''; };
2286
+ _snapshotLine = `\n[snapshot:${snapshotId} rev:${revision}]`;
2287
+ }
2288
+
2289
+ // ツリー構造情報のフォーマットヘルパー
2290
+ function formatTreeLine(b) {
2291
+ const depth = b.depth || 0;
2292
+ const indent = ' '.repeat(depth + 1);
2293
+ let marker = '';
2294
+ let childInfo = '';
2295
+ if (b.isContainer) {
2296
+ marker = '▼ ';
2297
+ if (b.columnCount) childInfo = ` [${b.columnCount}カラム]`;
2298
+ else if (b.tabNames && b.tabNames.length > 0) childInfo = ` [${b.tabNames.join(", ")}]`;
2299
+ else if (b.childCount) childInfo = ` [${b.childCount}子]`;
2300
+ }
2301
+ const parentInfo = (depth > 0 && b.parentIndex != null) ? `, p:${b.parentIndex}` : '';
2302
+ return { indent, marker, childInfo, depthInfo: `(d:${depth}${parentInfo})` };
2303
+ }
2304
+
1478
2305
  // ========================================
1479
2306
  // 全ブロック情報取得(full=true)
1480
2307
  // ========================================
@@ -1505,7 +2332,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1505
2332
 
1506
2333
  const blockList = blocks.map(b => {
1507
2334
  const attrPreview = JSON.stringify(b.attributes || {}).slice(0, 200);
1508
- return ` [${b.index}] ${b.type} - ${b.section || "(記事冒頭)"} (d:${b.depth})\n ${attrPreview}${attrPreview.length >= 200 ? '...' : ''}`;
2335
+ const t = formatTreeLine(b);
2336
+ return `${t.indent}[${b.index}${_refTag(b.index)}] ${t.marker}${b.type}${t.childInfo} - ${b.section || "(記事冒頭)"} ${t.depthInfo}\n${t.indent} ${attrPreview}${attrPreview.length >= 200 ? '...' : ''}`;
1509
2337
  }).join("\n\n");
1510
2338
 
1511
2339
  let paginationInfo = '';
@@ -1519,7 +2347,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1519
2347
  return {
1520
2348
  content: [{
1521
2349
  type: "text",
1522
- text: `📊 get_article_structure(${paramLabel})\n\n全ブロック情報 (${blocks.length}件):\n\n${blockList}${paginationInfo}${_modeTag_gas}`,
2350
+ text: `📊 get_article_structure(${paramLabel})\n\n全ブロック情報 (${blocks.length}件):\n\n${blockList}${paginationInfo}${_snapshotLine}${_modeTag_gas}`,
1523
2351
  }],
1524
2352
  };
1525
2353
  }
@@ -1556,6 +2384,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1556
2384
  type: block.type,
1557
2385
  section: block.section,
1558
2386
  depth: block.depth,
2387
+ parentIndex: block.parentIndex,
2388
+ isContainer: block.isContainer,
2389
+ childCount: block.childCount,
1559
2390
  preview: preview
1560
2391
  });
1561
2392
  }
@@ -1572,9 +2403,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1572
2403
 
1573
2404
  const matchList = matches
1574
2405
  .map(m => {
1575
- let line = ` index ${m.index}: ${m.type} - ${m.section || "(記事冒頭)"} (d:${m.depth})`;
2406
+ const t = formatTreeLine(m);
2407
+ let line = `${t.indent}index ${m.index}${_refTag(m.index, ' ')}: ${t.marker}${m.type}${t.childInfo} - ${m.section || "(記事冒頭)"} ${t.depthInfo}`;
1576
2408
  if (m.preview) {
1577
- line += `\n "${m.preview}"`;
2409
+ line += `\n${t.indent} "${m.preview}"`;
1578
2410
  }
1579
2411
  return line;
1580
2412
  })
@@ -1583,7 +2415,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1583
2415
  return {
1584
2416
  content: [{
1585
2417
  type: "text",
1586
- text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${matches.length}個):\n\n${matchList}${_modeTag_gas}`,
2418
+ text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${matches.length}個):\n\n${matchList}${_snapshotLine}${_modeTag_gas}`,
1587
2419
  }],
1588
2420
  };
1589
2421
  }
@@ -1613,9 +2445,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1613
2445
 
1614
2446
  const matchList = (searchResult.matches || [])
1615
2447
  .map(m => {
1616
- let line = ` index ${m.index}: ${m.type} - ${m.section || "(記事冒頭)"} (d:${m.depth})`;
2448
+ const t = formatTreeLine(m);
2449
+ let line = `${t.indent}index ${m.index}${_refTag(m.index, ' ')}: ${t.marker}${m.type}${t.childInfo} - ${m.section || "(記事冒頭)"} ${t.depthInfo}`;
1617
2450
  if (m.preview) {
1618
- line += `\n "${m.preview}"`;
2451
+ line += `\n${t.indent} "${m.preview}"`;
1619
2452
  }
1620
2453
  return line;
1621
2454
  })
@@ -1624,7 +2457,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1624
2457
  return {
1625
2458
  content: [{
1626
2459
  type: "text",
1627
- text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${(searchResult.matches || []).length}個):\n\n${matchList}${_modeTag_gas}`,
2460
+ text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${(searchResult.matches || []).length}個):\n\n${matchList}${_snapshotLine}${_modeTag_gas}`,
1628
2461
  }],
1629
2462
  };
1630
2463
  }
@@ -1698,12 +2531,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1698
2531
  }
1699
2532
 
1700
2533
  const blockList = filteredBlocks
1701
- .map(b => ` index ${b.index}: d:${b.depth || 0}`)
2534
+ .map(b => {
2535
+ const t = formatTreeLine(b);
2536
+ return `${t.indent}index ${b.index}${_refTag(b.index, ' ')}: ${t.marker}${blockType}${t.childInfo} ${t.depthInfo}`;
2537
+ })
1702
2538
  .join("\n");
1703
2539
  return {
1704
2540
  content: [{
1705
2541
  type: "text",
1706
- text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション内の ${blockType} (${filteredBlocks.length}個):\n\n${blockList}${_modeTag_gas}`,
2542
+ text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション内の ${blockType} (${filteredBlocks.length}個):\n\n${blockList}${_snapshotLine}${_modeTag_gas}`,
1707
2543
  }],
1708
2544
  };
1709
2545
  }
@@ -1723,19 +2559,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1723
2559
  const typeData = state.blockSummary[blockType];
1724
2560
  const blockList = typeData.blocks
1725
2561
  .map(b => {
1726
- let info = ` index ${b.index}: ${b.section || "(記事冒頭)"} (H${b.sectionLevel || "-"}) d:${b.depth || 0}`;
1727
- if (b.tabNames && b.tabNames.length > 0) {
1728
- info += ` [${b.tabNames.join(", ")}]`;
1729
- } else if (b.columnCount) {
1730
- info += ` [${b.columnCount}カラム]`;
1731
- }
1732
- return info;
2562
+ const t = formatTreeLine(b);
2563
+ return `${t.indent}index ${b.index}${_refTag(b.index, ' ')}: ${t.marker}${b.section || "(記事冒頭)"} (H${b.sectionLevel || "-"})${t.childInfo} ${t.depthInfo}`;
1733
2564
  })
1734
2565
  .join("\n");
1735
2566
  return {
1736
2567
  content: [{
1737
2568
  type: "text",
1738
- text: `📊 get_article_structure(${paramLabel})\n\n${blockType} の一覧 (${typeData.count}個):\n\n${blockList}${_modeTag_gas}`,
2569
+ text: `📊 get_article_structure(${paramLabel})\n\n${blockType} の一覧 (${typeData.count}個):\n\n${blockList}${_snapshotLine}${_modeTag_gas}`,
1739
2570
  }],
1740
2571
  };
1741
2572
  }
@@ -1793,20 +2624,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1793
2624
 
1794
2625
  const blockList = sectionBlocks
1795
2626
  .map(b => {
1796
- const indent = ' '.repeat((b.depth || 0) + 1);
1797
- let marker = '';
1798
- let info = '';
1799
- if (b.isContainer) {
1800
- marker = '▼ ';
1801
- if (b.columnCount) {
1802
- info = ` [${b.columnCount}カラム]`;
1803
- } else if (b.tabNames && b.tabNames.length > 0) {
1804
- info = ` [${b.tabNames.join(", ")}]`;
1805
- } else if (b.childCount) {
1806
- info = ` [${b.childCount}子]`;
1807
- }
1808
- }
1809
- return `${indent}${b.index}: ${marker}${b.type}${info} (d:${b.depth || 0})`;
2627
+ const t = formatTreeLine(b);
2628
+ return `${t.indent}${b.index}${_refTag(b.index, ' ')}: ${t.marker}${b.type}${t.childInfo} ${t.depthInfo}`;
1810
2629
  })
1811
2630
  .join("\n");
1812
2631
 
@@ -1817,7 +2636,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1817
2636
  return {
1818
2637
  content: [{
1819
2638
  type: "text",
1820
- text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション (index:${sectionHeading.index}):\n\n${blockList}${nextInfo}${_modeTag_gas}`,
2639
+ text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション (index:${sectionHeading.index}):\n\n${blockList}${nextInfo}${_snapshotLine}${_modeTag_gas}`,
1821
2640
  }],
1822
2641
  };
1823
2642
  }
@@ -1836,12 +2655,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1836
2655
  };
1837
2656
  }
1838
2657
  const headingList = filteredHeadings
1839
- .map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index})`)
2658
+ .map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index}${_refTag(h.index)})`)
1840
2659
  .join("\n");
1841
2660
  return {
1842
2661
  content: [{
1843
2662
  type: "text",
1844
- text: `📊 get_article_structure(${paramLabel})\n\nH${headingLevel}見出し一覧 (${filteredHeadings.length}個):\n\n${headingList}${_modeTag_gas}`,
2663
+ text: `📊 get_article_structure(${paramLabel})\n\nH${headingLevel}見出し一覧 (${filteredHeadings.length}個):\n\n${headingList}${_snapshotLine}${_modeTag_gas}`,
1845
2664
  }],
1846
2665
  };
1847
2666
  }
@@ -1854,11 +2673,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1854
2673
  for (const heading of state.headings) {
1855
2674
  const indent = heading.level === 2 ? "" : " ";
1856
2675
  const prefix = "#".repeat(heading.level);
1857
- output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index})\n`;
2676
+ output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index}${_refTag(heading.index)})\n`;
1858
2677
  }
1859
2678
 
1860
2679
  return {
1861
- content: [{ type: "text", text: output + _modeTag_gas }],
2680
+ content: [{ type: "text", text: output + _snapshotLine + _modeTag_gas }],
1862
2681
  };
1863
2682
  }
1864
2683
 
@@ -1916,7 +2735,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1916
2735
  }
1917
2736
 
1918
2737
  case "delete_block": {
1919
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
2738
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
1920
2739
  if (mode === 'error') {
1921
2740
  return errorResponse(name, message, args?.site);
1922
2741
  }
@@ -1925,13 +2744,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1925
2744
  if (_resolved.editorConnected) mode = 'editor';
1926
2745
  const postId = _resolved.postId ?? _postId;
1927
2746
  const _mt_db = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
2747
+ const _siteName = siteName || args?.site || 'default';
1928
2748
 
1929
2749
  if (mode === 'headless') {
1930
2750
  const _guard = await guardHeadlessConflict(postId, client, name);
1931
2751
  if (_guard) return _guard;
1932
2752
  }
1933
2753
 
1934
- const { index, count, indices } = args;
2754
+ // ref/refs index/indices 排他チェック
2755
+ if ((args?.ref || args?.refs) && (args?.index !== undefined || args?.indices !== undefined)) {
2756
+ return { content: [{ type: "text", text: "❌ ref/refs と index/indices は同時に指定できません。" }], isError: true };
2757
+ }
2758
+
2759
+ // ref 解決 + revision チェック
2760
+ const resolved = await resolveRefsAndCheckRevision({
2761
+ snapshotId: args?.snapshotId, ref: args?.ref, refs: args?.refs,
2762
+ expectedRevision: args?.expectedRevision,
2763
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
2764
+ });
2765
+ if (resolved.error) return resolved.error;
2766
+
2767
+ let { index, count, indices } = args;
2768
+ if (resolved.index !== undefined) { index = resolved.index; count = count || 1; }
2769
+ if (resolved.indices !== undefined) { indices = resolved.indices; }
1935
2770
 
1936
2771
  // indices と index/count の排他バリデーション
1937
2772
  if (indices !== undefined && (index !== undefined || count !== undefined)) {
@@ -1953,7 +2788,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1953
2788
  try {
1954
2789
  const result = await client.headlessDeleteMultiple(postId, uniqueIndices);
1955
2790
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1956
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
2791
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2792
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`, _snap) }] };
1957
2793
  } catch (e) {
1958
2794
  const formatted = formatHeadlessConflictError(e);
1959
2795
  if (formatted) return formatted;
@@ -1967,7 +2803,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1967
2803
  if (!result.success)
1968
2804
  return errorResponse(name, result.error, args?.site);
1969
2805
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1970
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
2806
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2807
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`, _snap) }] };
1971
2808
  }
1972
2809
 
1973
2810
  // 既存モード(index + count)
@@ -1978,7 +2815,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1978
2815
  try {
1979
2816
  const result = await client.headlessDelete(postId, index, count || 1);
1980
2817
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1981
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
2818
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2819
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`, _snap) }] };
1982
2820
  } catch (e) {
1983
2821
  const formatted = formatHeadlessConflictError(e);
1984
2822
  if (formatted) return formatted;
@@ -1992,11 +2830,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1992
2830
  if (!result.success)
1993
2831
  return errorResponse(name, result.error, args?.site);
1994
2832
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1995
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
2833
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2834
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`, _snap) }] };
1996
2835
  }
1997
2836
 
1998
2837
  case "move_block": {
1999
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
2838
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2000
2839
  if (mode === 'error') {
2001
2840
  return errorResponse(name, message, args?.site);
2002
2841
  }
@@ -2005,13 +2844,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2005
2844
  if (_resolved.editorConnected) mode = 'editor';
2006
2845
  const postId = _resolved.postId ?? _postId;
2007
2846
  const _mt_mb = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
2847
+ const _siteName = siteName || args?.site || 'default';
2008
2848
 
2009
2849
  if (mode === 'headless') {
2010
2850
  const _guard = await guardHeadlessConflict(postId, client, name);
2011
2851
  if (_guard) return _guard;
2012
2852
  }
2013
2853
 
2014
- const { from, to, fromFlat, toFlat } = args;
2854
+ const { from, to } = args;
2855
+ let { fromFlat, toFlat } = args;
2015
2856
  const count = args.count ?? 1;
2016
2857
 
2017
2858
  // count バリデーション
@@ -2019,14 +2860,63 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2019
2860
  return { content: [{ type: "text", text: "❌ count は1以上の整数を指定してください。" }], isError: true };
2020
2861
  }
2021
2862
 
2022
- // 排他バリデーション
2863
+ // 3 モード排他バリデーション
2023
2864
  const hasTopLevel = from !== undefined || to !== undefined;
2024
2865
  const hasFlat = fromFlat !== undefined || toFlat !== undefined;
2025
- if (hasTopLevel && hasFlat) {
2026
- return { content: [{ type: "text", text: "❌ from/to と fromFlat/toFlat は同時に指定できません。" }], isError: true };
2866
+ const hasRef = args?.fromRef !== undefined || args?.beforeRef !== undefined || args?.afterRef !== undefined;
2867
+ const _modeCount = [hasTopLevel, hasFlat, hasRef].filter(Boolean).length;
2868
+
2869
+ if (_modeCount > 1) {
2870
+ return { content: [{ type: "text", text: "❌ from/to, fromFlat/toFlat, ref(fromRef/beforeRef/afterRef) は同時に指定できません。" }], isError: true };
2871
+ }
2872
+ if (_modeCount === 0) {
2873
+ return { content: [{ type: "text", text: "❌ from/to, fromFlat/toFlat, または fromRef+beforeRef/afterRef を指定してください。" }], isError: true };
2027
2874
  }
2028
- if (!hasTopLevel && !hasFlat) {
2029
- return { content: [{ type: "text", text: "❌ from/to または fromFlat/toFlat を指定してください。" }], isError: true };
2875
+
2876
+ // expectedRevision チェック(全モード共通)
2877
+ // ref モード以外でも expectedRevision が指定されていれば revision チェックを実施
2878
+ if (args?.expectedRevision && !hasRef) {
2879
+ const { error: _revError } = await acquireFreshState({
2880
+ expectedRevision: args.expectedRevision,
2881
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
2882
+ });
2883
+ if (_revError) return _revError;
2884
+ }
2885
+
2886
+ // ref モード: fromRef + beforeRef/afterRef
2887
+ if (hasRef) {
2888
+ if (!args.fromRef) {
2889
+ return { content: [{ type: "text", text: "❌ ref モードでは fromRef が必須です。" }], isError: true };
2890
+ }
2891
+ if (!args.beforeRef && !args.afterRef) {
2892
+ return { content: [{ type: "text", text: "❌ ref モードでは beforeRef または afterRef が必須です。" }], isError: true };
2893
+ }
2894
+ if (args.beforeRef && args.afterRef) {
2895
+ return { content: [{ type: "text", text: "❌ beforeRef と afterRef は同時に指定できません。" }], isError: true };
2896
+ }
2897
+ if (!args.snapshotId) {
2898
+ return { content: [{ type: "text", text: "❌ ref を使用するには snapshotId が必要です。get_article_structure で取得してください。" }], isError: true };
2899
+ }
2900
+
2901
+ // acquireFreshState で 1 回だけ state 取得 + revision チェック
2902
+ const { currentState, error: _stateError } = await acquireFreshState({
2903
+ expectedRevision: args.expectedRevision,
2904
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
2905
+ });
2906
+ if (_stateError) return _stateError;
2907
+
2908
+ // ref 解決(同じ currentState で 2 つの ref を解決)
2909
+ try {
2910
+ fromFlat = resolveRefFromState(args.snapshotId, args.fromRef, mode, _sessionId, postId, currentState);
2911
+ const destRef = args.beforeRef || args.afterRef;
2912
+ const resolvedDest = resolveRefFromState(args.snapshotId, destRef, mode, _sessionId, postId, currentState);
2913
+ // 位置計算: beforeRef → そのまま, afterRef → +1
2914
+ toFlat = args.beforeRef ? resolvedDest : resolvedDest + 1;
2915
+ } catch (e) {
2916
+ return { content: [{ type: "text", text: `❌ ref 解決エラー: ${e.message}` }], isError: true };
2917
+ }
2918
+
2919
+ // ref → flat に変換完了。以降 flat モードに合流
2030
2920
  }
2031
2921
 
2032
2922
  // 移動結果のレスポンス生成ヘルパー
@@ -2039,8 +2929,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2039
2929
  : `✅ ブロック移動 (${typeLabel})${_mt_mb}`;
2040
2930
  };
2041
2931
 
2042
- // fromFlat/toFlat モード(フラットインデックス移動)
2043
- if (hasFlat) {
2932
+ // fromFlat/toFlat モード(フラットインデックス移動)— ref モードもここに合流
2933
+ if (hasFlat || hasRef) {
2044
2934
  if (fromFlat === undefined || toFlat === undefined) {
2045
2935
  return { content: [{ type: "text", text: "❌ fromFlat と toFlat の両方を指定してください。" }], isError: true };
2046
2936
  }
@@ -2051,7 +2941,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2051
2941
  if (mode === 'headless') {
2052
2942
  try {
2053
2943
  const result = await client.headlessMoveFlat(postId, fromFlat, toFlat, count);
2054
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
2944
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2945
+ return { content: [{ type: "text", text: appendSnapshotToText(moveMsg(result.moved), _snap) }] };
2055
2946
  } catch (e) {
2056
2947
  const formatted = formatHeadlessConflictError(e);
2057
2948
  if (formatted) return formatted;
@@ -2064,7 +2955,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2064
2955
  return timeoutResponse(name, client, args?.site);
2065
2956
  if (!result.success)
2066
2957
  return errorResponse(name, result.error, args?.site);
2067
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
2958
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2959
+ return { content: [{ type: "text", text: appendSnapshotToText(moveMsg(result.moved), _snap) }] };
2068
2960
  }
2069
2961
 
2070
2962
  // 既存モード(from/to トップレベル)
@@ -2075,7 +2967,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2075
2967
  if (mode === 'headless') {
2076
2968
  try {
2077
2969
  const result = await client.headlessMove(postId, from, to, count);
2078
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
2970
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2971
+ return { content: [{ type: "text", text: appendSnapshotToText(moveMsg(result.moved), _snap) }] };
2079
2972
  } catch (e) {
2080
2973
  const formatted = formatHeadlessConflictError(e);
2081
2974
  if (formatted) return formatted;
@@ -2088,7 +2981,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2088
2981
  return timeoutResponse(name, client, args?.site);
2089
2982
  if (!result.success)
2090
2983
  return errorResponse(name, result.error, args?.site);
2091
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
2984
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
2985
+ return { content: [{ type: "text", text: appendSnapshotToText(moveMsg(result.moved), _snap) }] };
2092
2986
  }
2093
2987
 
2094
2988
  case "undo": {
@@ -2124,7 +3018,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2124
3018
  }
2125
3019
 
2126
3020
  case "duplicate_block": {
2127
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
3021
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2128
3022
  if (mode === 'error') {
2129
3023
  return errorResponse(name, message, args?.site);
2130
3024
  }
@@ -2133,8 +3027,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2133
3027
  if (_resolved.editorConnected) mode = 'editor';
2134
3028
  const postId = _resolved.postId ?? _postId;
2135
3029
  const _mt_dup = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
3030
+ const _siteName = siteName || args?.site || 'default';
3031
+
3032
+ // ref と index 排他チェック
3033
+ if (args?.ref && args?.index !== undefined) {
3034
+ return { content: [{ type: "text", text: "❌ ref と index は同時に指定できません。" }], isError: true };
3035
+ }
3036
+
3037
+ // ref 解決 + revision チェック
3038
+ const resolved = await resolveRefsAndCheckRevision({
3039
+ snapshotId: args?.snapshotId, ref: args?.ref,
3040
+ expectedRevision: args?.expectedRevision,
3041
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
3042
+ });
3043
+ if (resolved.error) return resolved.error;
2136
3044
 
2137
- const { index } = args;
3045
+ const index = resolved.index !== undefined ? resolved.index : args?.index;
2138
3046
 
2139
3047
  if (mode === 'headless') {
2140
3048
  const _guard = await guardHeadlessConflict(postId, client, name);
@@ -2144,7 +3052,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2144
3052
  }
2145
3053
  try {
2146
3054
  const result = await client.headlessDuplicate(postId, index);
2147
- return { content: [{ type: "text", text: `✅ ブロック複製完了 (index ${result.duplicated.index} → ${result.duplicated.newIndex})${_mt_dup}` }] };
3055
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
3056
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ブロック複製完了 (index ${result.duplicated.index} → ${result.duplicated.newIndex})${_mt_dup}`, _snap) }] };
2148
3057
  } catch (e) {
2149
3058
  const formatted = formatHeadlessConflictError(e);
2150
3059
  if (formatted) return formatted;
@@ -2157,7 +3066,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2157
3066
  return timeoutResponse(name, client, args?.site);
2158
3067
  if (!result.success)
2159
3068
  return errorResponse(name, result.error, args?.site);
2160
- return { content: [{ type: "text", text: `✅ ブロック複製完了${_mt_dup}` }] };
3069
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
3070
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ブロック複製完了${_mt_dup}`, _snap) }] };
2161
3071
  }
2162
3072
 
2163
3073
  case "save_post": {
@@ -2362,13 +3272,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2362
3272
  }
2363
3273
 
2364
3274
  case "insert_block": {
2365
- let { rawHTML, filePath, index } = (args || {});
3275
+ let { rawHTML, filePath, index, position } = (args || {});
3276
+
3277
+ // parentIndex は廃止済み(v3.0.0 Phase 5)— position を使用
3278
+ if (args?.parentIndex !== undefined) {
3279
+ return { content: [{ type: "text", text: "❌ parentIndex は廃止されました。代わりに index + position ('before'/'after') を使用してください。" }], isError: true };
3280
+ }
2366
3281
 
2367
3282
  // 排他チェック
2368
3283
  if (rawHTML && filePath) {
2369
3284
  return { content: [{ type: "text", text: "❌ rawHTML と filePath は同時に指定できません。どちらか一方を指定してください。" }], isError: true };
2370
3285
  }
2371
3286
 
3287
+ // Phase 4b: beforeRef/afterRef と index/position の排他チェック
3288
+ const _hasRefPosition = args?.beforeRef !== undefined || args?.afterRef !== undefined;
3289
+ const _hasIndexPosition = index !== undefined || position !== undefined;
3290
+ if (_hasRefPosition && _hasIndexPosition) {
3291
+ return { content: [{ type: "text", text: "❌ beforeRef/afterRef と index/position は同時に指定できません。" }], isError: true };
3292
+ }
3293
+ if (args?.beforeRef && args?.afterRef) {
3294
+ return { content: [{ type: "text", text: "❌ beforeRef と afterRef は同時に指定できません。" }], isError: true };
3295
+ }
3296
+
3297
+ // position 指定時は index 必須(ref モードでないとき)
3298
+ if (!_hasRefPosition && position !== undefined && index === undefined) {
3299
+ return { content: [{ type: "text", text: "❌ position を指定する場合は index も指定してください。末尾追加は index を省略してください。" }], isError: true };
3300
+ }
3301
+
3302
+ // position バリデーション
3303
+ if (position !== undefined && position !== "before" && position !== "after") {
3304
+ return { content: [{ type: "text", text: `❌ position は 'before' または 'after' を指定してください: ${position}` }], isError: true };
3305
+ }
3306
+
2372
3307
  // filePath → rawHTML 解決
2373
3308
  if (filePath) {
2374
3309
  try { rawHTML = readHTMLFromFile(filePath).html; }
@@ -2378,21 +3313,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2378
3313
  return { content: [{ type: "text", text: "❌ rawHTML または filePath を指定してください。Gutenberg HTML 形式のブロックマークアップが必要です。" }], isError: true };
2379
3314
  }
2380
3315
 
2381
- // index バリデーション(現行 Editor 互換: 整数 >= 0
2382
- if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
3316
+ // index バリデーション(現行 Editor 互換: 整数 >= 0)— ref モードでないとき
3317
+ if (!_hasRefPosition && index !== undefined && (!Number.isInteger(index) || index < 0)) {
2383
3318
  return { content: [{ type: "text", text: `❌ Invalid index: ${index}` }], isError: true };
2384
3319
  }
2385
3320
 
2386
- // update_blocks コードパスに委譲(新形式 target/insert を使用)
3321
+ // update_blocks コードパスに委譲
3322
+ // expectedRevision は全モードで委譲(ref/index/append 問わず)
2387
3323
  const delegatedArgs = {
2388
3324
  postId: args.postId,
2389
3325
  site: args.site,
2390
3326
  newHTML: rawHTML,
2391
- insert: { position: 'before' },
2392
3327
  _fromInsertBlock: true,
2393
- appendToEnd: index === undefined,
3328
+ expectedRevision: args.expectedRevision,
2394
3329
  };
2395
- if (index !== undefined) delegatedArgs.target = { index };
3330
+
3331
+ if (_hasRefPosition) {
3332
+ // ref モード: target.ref + insert.position で委譲
3333
+ const _ref = args.beforeRef || args.afterRef;
3334
+ delegatedArgs.target = { ref: _ref };
3335
+ delegatedArgs.insert = { position: args.beforeRef ? 'before' : 'after' };
3336
+ delegatedArgs.snapshotId = args.snapshotId;
3337
+ delegatedArgs.appendToEnd = false;
3338
+ } else if (index !== undefined) {
3339
+ // 既存: index モード
3340
+ delegatedArgs.target = { index };
3341
+ delegatedArgs.insert = { position: position ?? 'before' };
3342
+ delegatedArgs.appendToEnd = false;
3343
+ } else {
3344
+ // 末尾追加
3345
+ delegatedArgs.insert = { position: 'before' };
3346
+ delegatedArgs.appendToEnd = true;
3347
+ }
3348
+
2396
3349
  return await handleUpdateBlocksTool(delegatedArgs, name);
2397
3350
  }
2398
3351
 
@@ -2510,7 +3463,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2510
3463
  }
2511
3464
 
2512
3465
  case "table_operations": {
2513
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
3466
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2514
3467
  if (mode === 'error') {
2515
3468
  return errorResponse(name, message, args?.site);
2516
3469
  }
@@ -2518,15 +3471,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2518
3471
  if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
2519
3472
  if (_resolved.editorConnected) mode = 'editor';
2520
3473
  const postId = _resolved.postId ?? _postId;
3474
+ const _siteName = siteName || args?.site || 'default';
2521
3475
 
2522
- const { index, action } = (args || {});
2523
- if (index === undefined) {
2524
- return { content: [{ type: "text", text: "❌ index は必須です" }], isError: true };
2525
- }
3476
+ const { action } = (args || {});
2526
3477
  if (!action) {
2527
3478
  return { content: [{ type: "text", text: "❌ action は必須です" }], isError: true };
2528
3479
  }
2529
3480
 
3481
+ // ref と index 排他チェック
3482
+ if (args?.ref && args?.index !== undefined) {
3483
+ return { content: [{ type: "text", text: "❌ ref と index は同時に指定できません。" }], isError: true };
3484
+ }
3485
+
3486
+ // ref 解決 + revision チェック
3487
+ const resolved = await resolveRefsAndCheckRevision({
3488
+ snapshotId: args?.snapshotId, ref: args?.ref,
3489
+ expectedRevision: args?.expectedRevision,
3490
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
3491
+ });
3492
+ if (resolved.error) return resolved.error;
3493
+
3494
+ const index = resolved.index !== undefined ? resolved.index : args?.index;
3495
+ if (index === undefined) {
3496
+ return { content: [{ type: "text", text: "❌ table_operations では index または ref のいずれかが必要です。" }], isError: true };
3497
+ }
3498
+
2530
3499
  const tableParams = {
2531
3500
  index, action,
2532
3501
  row: args.row, col: args.col,
@@ -2557,16 +3526,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2557
3526
  return text;
2558
3527
  };
2559
3528
 
3529
+ const _isWrite = action !== 'get_structure';
2560
3530
  const _mt_to = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
2561
3531
  if (mode === 'headless') {
2562
3532
  const _guard = await guardHeadlessConflict(postId, client, name);
2563
3533
  if (_guard) return _guard;
2564
3534
  try {
2565
3535
  const result = await client.headlessTableOperation(postId, tableParams);
2566
- if (action === 'get_structure') {
3536
+ if (!_isWrite) {
2567
3537
  return { content: [{ type: "text", text: formatStructure(result) + _mt_to }] };
2568
3538
  }
2569
- return { content: [{ type: "text", text: `✅ ${action} 完了: ${JSON.stringify(result.detail || result)}${_mt_to}` }] };
3539
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
3540
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${action} 完了: ${JSON.stringify(result.detail || result)}${_mt_to}`, _snap) }] };
2570
3541
  } catch (e) {
2571
3542
  const formatted = formatHeadlessConflictError(e);
2572
3543
  if (formatted) return formatted;
@@ -2581,10 +3552,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2581
3552
  if (!result.success)
2582
3553
  return errorResponse(name, result.error, args?.site);
2583
3554
 
2584
- if (action === 'get_structure') {
3555
+ if (!_isWrite) {
2585
3556
  return { content: [{ type: "text", text: formatStructure(result.structure) + _mt_to }] };
2586
3557
  }
2587
- return { content: [{ type: "text", text: `✅ ${action} 完了: ${JSON.stringify(result.detail || {})}${_mt_to}` }] };
3558
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName);
3559
+ return { content: [{ type: "text", text: appendSnapshotToText(`✅ ${action} 完了: ${JSON.stringify(result.detail || {})}${_mt_to}`, _snap) }] };
2588
3560
  }
2589
3561
 
2590
3562
  case "open_in_browser": {