friday-mcp-v2 2.0.5 → 2.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.
@@ -10,61 +10,553 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
11
11
  import { FridayWPClient, ConnectionRegistry } from "./wordpress-api.js";
12
12
  import fetch from "node-fetch";
13
+ import { readFileSync, statSync, realpathSync } from "node:fs";
14
+ import { execFile } from "node:child_process";
15
+ import { resolve as resolvePath, sep, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ // package.json からバージョンを取得
18
+ const __pkg_dirname = dirname(fileURLToPath(import.meta.url));
19
+ const SERVER_VERSION = (() => {
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(resolvePath(__pkg_dirname, '..', 'package.json'), 'utf-8'));
22
+ return pkg.version || '0.0.0';
23
+ } catch { return '0.0.0'; }
24
+ })();
25
+
26
+ // .env 読み込み(FRIDAY_WORKSPACE 配下の .env を自動ロード)
27
+ // .mcp.json の env が優先され、.env は未設定キーの補完のみ
28
+ const _envWorkspace = process.env.FRIDAY_WORKSPACE;
29
+ if (_envWorkspace) {
30
+ try {
31
+ const _envPath = resolvePath(_envWorkspace, '.env');
32
+ const _envContent = readFileSync(_envPath, 'utf-8');
33
+ for (const _line of _envContent.split('\n')) {
34
+ const _trimmed = _line.trim();
35
+ if (!_trimmed || _trimmed.startsWith('#')) continue;
36
+ const _eqIdx = _trimmed.indexOf('=');
37
+ if (_eqIdx === -1) continue;
38
+ const _key = _trimmed.slice(0, _eqIdx).trim();
39
+ const _val = _trimmed.slice(_eqIdx + 1).trim();
40
+ if (!process.env[_key]) {
41
+ process.env[_key] = _val;
42
+ }
43
+ }
44
+ } catch (_e) {
45
+ // .env がなくても起動は続行
46
+ }
47
+ }
13
48
 
14
49
  const registry = new ConnectionRegistry();
15
50
 
51
+ // Sticky site キャッシュ: 自動検出した接続先を短時間記憶
52
+ const statusCache = {
53
+ _entries: new Map(),
54
+ _lastSite: null,
55
+ TTL: 15_000,
56
+
57
+ /**
58
+ * 指定サイトのキャッシュを取得(TTL 切れなら自動破棄)
59
+ * @param {string} site
60
+ * @returns {{ site: string, client: FridayWPClient, postId: number, checkedAt: number } | null}
61
+ */
62
+ get(site) {
63
+ const entry = this._entries.get(site);
64
+ if (!entry) return null;
65
+ if ((Date.now() - entry.checkedAt) >= this.TTL) {
66
+ this._entries.delete(site);
67
+ if (this._lastSite === site) this._lastSite = null;
68
+ return null;
69
+ }
70
+ return { site, ...entry };
71
+ },
72
+
73
+ /**
74
+ * 最後に set されたサイトのキャッシュを返す(sticky 参照用)
75
+ * 現行の isValid() + 単一スロット参照と同じセマンティクスを維持する。
76
+ * Map 走査順に依存せず、明示的に _lastSite を追跡することで
77
+ * 既存の検出限界(キャッシュ/default ヒット時は複数 editor 未検出)を含めて維持する。
78
+ */
79
+ getLast() {
80
+ if (!this._lastSite) return null;
81
+ return this.get(this._lastSite);
82
+ },
83
+
84
+ set(site, client, postId) {
85
+ this._entries.set(site, { client, postId, checkedAt: Date.now() });
86
+ this._lastSite = site;
87
+ },
88
+
89
+ /**
90
+ * @param {string} [site] 指定時はそのサイトのみ破棄。省略時は全破棄。
91
+ */
92
+ clear(site) {
93
+ if (site) {
94
+ this._entries.delete(site);
95
+ if (this._lastSite === site) this._lastSite = null;
96
+ } else {
97
+ this._entries.clear();
98
+ this._lastSite = null;
99
+ }
100
+ }
101
+ };
102
+
16
103
  // ヘルパー関数: 正規表現用に文字列をエスケープ
17
104
  function escapeRegExp(string) {
18
105
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
106
  }
20
107
 
21
108
  /**
22
- * Editor/Headless モード判定ヘルパー
23
- * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
109
+ * OS のデフォルトブラウザで URL を開く(execFile でシェル非経由)
24
110
  */
25
- async function resolveMode(args) {
26
- let client;
111
+ function openInBrowser(url) {
112
+ return new Promise((resolve, reject) => {
113
+ let cmd, args;
114
+ switch (process.platform) {
115
+ case 'win32':
116
+ cmd = 'rundll32';
117
+ args = ['url.dll,FileProtocolHandler', url];
118
+ break;
119
+ case 'darwin':
120
+ cmd = 'open';
121
+ args = [url];
122
+ break;
123
+ default:
124
+ cmd = 'xdg-open';
125
+ args = [url];
126
+ break;
127
+ }
128
+ execFile(cmd, args, (error) => {
129
+ if (error) reject(error);
130
+ else resolve();
131
+ });
132
+ });
133
+ }
134
+
135
+ /**
136
+ * URL バリデーション(http/https のみ許可)
137
+ */
138
+ function isValidHttpUrl(url) {
27
139
  try {
28
- client = registry.get(args?.site);
29
- } catch (e) {
30
- return { mode: 'error', message: e.message };
140
+ const parsed = new URL(url);
141
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * filePath からHTMLを読み込む共通ユーティリティ
149
+ * FRIDAY_WORKSPACE 環境変数で許可ディレクトリを制限
150
+ */
151
+ function readHTMLFromFile(filePath, maxSizeBytes = 2 * 1024 * 1024) {
152
+ const resolved = resolvePath(filePath);
153
+
154
+ const workspace = process.env.FRIDAY_WORKSPACE;
155
+ if (!workspace) {
156
+ throw new Error('FRIDAY_WORKSPACE 環境変数が未設定です。.mcp.json の env で設定してください。');
157
+ }
158
+
159
+ // セキュリティ: 実体パスで比較(シンボリックリンク/ジャンクション経由の迂回を防止)
160
+ let realPath;
161
+ try { realPath = realpathSync(resolved); }
162
+ catch (e) {
163
+ if (e.code === 'ENOENT') throw new Error(`ファイルが見つかりません: ${resolved}`);
164
+ throw new Error(`ファイルアクセスエラー: ${resolved} (${e.message})`);
165
+ }
166
+ let realRoot;
167
+ try { realRoot = realpathSync(resolvePath(workspace)); }
168
+ catch (e) {
169
+ throw new Error(`FRIDAY_WORKSPACE のパスが無効です: ${workspace} (${e.message})`);
170
+ }
171
+ if (!realPath.startsWith(realRoot + sep) && realPath !== realRoot) {
172
+ throw new Error(`許可されたディレクトリ外です: ${realPath} (FRIDAY_WORKSPACE: ${realRoot})`);
31
173
  }
32
174
 
175
+ let stat;
176
+ try { stat = statSync(realPath); }
177
+ catch (e) {
178
+ throw new Error(`ファイルアクセスエラー: ${realPath} (${e.message})`);
179
+ }
180
+ if (stat.size > maxSizeBytes) {
181
+ throw new Error(`ファイルサイズ超過: ${(stat.size / 1048576).toFixed(1)}MB (上限: ${(maxSizeBytes / 1048576).toFixed(1)}MB)`);
182
+ }
183
+ let html;
184
+ try { html = readFileSync(realPath, 'utf-8'); }
185
+ catch (e) { throw new Error(`ファイル読み込み失敗: ${realPath} (${e.message})`); }
186
+ if (!html.trim()) throw new Error(`ファイルが空です: ${realPath}`);
187
+ return { html };
188
+ }
189
+
190
+ /**
191
+ * 単一クライアントの Editor/Headless 判定
192
+ * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
193
+ */
194
+ async function checkSingleClient(client, args) {
33
195
  try {
34
- const status = await client.getStatus();
196
+ const rawPostId = args?.postId;
197
+ const numPostId = rawPostId != null ? Number(rawPostId) : null;
198
+ const isNumeric = numPostId != null && Number.isFinite(numPostId) && numPostId > 0;
199
+ // getStatus の checkPostId は数値のみ意味がある。スラッグ/空文字時は null
200
+ const status = await client.getStatus(isNumeric ? numPostId : null);
201
+
35
202
  if (status.editorConnected && status.postId) {
36
- const argPostId = args?.postId;
37
- if (argPostId && argPostId !== status.postId) {
38
- return { mode: 'headless', postId: argPostId, client };
203
+ if (rawPostId != null) {
204
+ if (isNumeric) {
205
+ if (numPostId === Number(status.postId) || status.checkedEditorConnected) {
206
+ return {
207
+ mode: 'error',
208
+ errorCode: 'editor_conflict',
209
+ client,
210
+ message: `postId ${numPostId} はエディタで開かれています。postId を省略してエディタ経由で操作するか、エディタを閉じてから Headless モードを使用してください。`
211
+ };
212
+ }
213
+ return { mode: 'headless', postId: numPostId, client };
214
+ }
215
+ // スラッグ指定 → headless(ID 解決 + 競合チェックは resolvePostId で行う)
216
+ return { mode: 'headless', postId: null, client };
39
217
  }
40
218
  return { mode: 'editor', postId: status.postId, client };
41
219
  }
42
- const postId = args?.postId;
43
- if (!postId) {
44
- return { mode: 'error', message: 'エディタ未接続です。postId を指定して Headless モードを使用してください。' };
220
+
221
+ if (isNumeric && status.checkedEditorConnected) {
222
+ return {
223
+ mode: 'error',
224
+ errorCode: 'editor_conflict',
225
+ client,
226
+ message: `postId ${numPostId} はエディタで開かれています。postId を省略してエディタ経由で操作するか、エディタを閉じてから Headless モードを使用してください。`
227
+ };
45
228
  }
46
- return { mode: 'headless', postId, client };
47
- } catch (e) {
48
- const postId = args?.postId;
49
- if (postId) {
50
- return { mode: 'headless', postId, client };
229
+
230
+ if (rawPostId == null) {
231
+ return { mode: 'error', message: 'エディタ未接続' };
232
+ }
233
+ if (isNumeric) {
234
+ return { mode: 'headless', postId: numPostId, client };
51
235
  }
236
+ // スラッグ指定
237
+ return { mode: 'headless', postId: null, client };
238
+ } catch (e) {
52
239
  return { mode: 'error', message: `接続エラー: ${e.message}` };
53
240
  }
54
241
  }
55
242
 
243
+ /**
244
+ * Editor/Headless モード判定ヘルパー(複数接続自動検出対応)
245
+ * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
246
+ */
247
+ async function resolveMode(args) {
248
+ // site 明示指定
249
+ if (args?.site) {
250
+ let client;
251
+ try { client = registry.get(args.site); }
252
+ catch (e) { return { mode: 'error', message: e.message }; }
253
+ const result = await checkSingleClient(client, args);
254
+ return { ...result, siteName: args.site };
255
+ }
256
+
257
+ const allConns = registry.getAll();
258
+ const isMultiSite = allConns.length > 1;
259
+
260
+ // 単一接続 → 従来通り
261
+ if (!isMultiSite) {
262
+ const result = await checkSingleClient(registry.get(), args);
263
+ return { ...result, siteName: allConns[0].name };
264
+ }
265
+
266
+ // 複数接続 + sticky キャッシュ有効
267
+ const cached = statusCache.getLast();
268
+ if (cached) {
269
+ const cachedResult = await checkSingleClient(cached.client, args);
270
+ if (cachedResult.mode === 'editor') {
271
+ return { ...cachedResult, siteName: cached.site };
272
+ }
273
+ statusCache.clear(cached.site);
274
+ }
275
+
276
+ // default を先に確認
277
+ const defaultClient = registry.get();
278
+ const defaultName = allConns.find(c => c.client === defaultClient)?.name || 'default';
279
+ const defaultResult = await checkSingleClient(defaultClient, args);
280
+ if (defaultResult.mode === 'editor') {
281
+ statusCache.set(defaultName, defaultClient, defaultResult.postId);
282
+ return { ...defaultResult, siteName: defaultName };
283
+ }
284
+ // defaultResult.mode === 'headless' の場合:
285
+ // 他の接続にエディタが接続中の可能性があるため、並列スキャンに進む
286
+
287
+ // 全接続並列探索(各接続 5秒タイムアウト付き)
288
+ const SCAN_TIMEOUT = 5000;
289
+ const statusResults = await Promise.allSettled(
290
+ allConns.map(async ({ name, client }) => {
291
+ const status = await Promise.race([
292
+ client.getStatus(),
293
+ new Promise((_, reject) =>
294
+ setTimeout(() => reject(new Error('timeout')), SCAN_TIMEOUT)
295
+ )
296
+ ]);
297
+ return { name, client, status };
298
+ })
299
+ );
300
+
301
+ const connected = statusResults
302
+ .filter(r => r.status === 'fulfilled')
303
+ .map(r => r.value)
304
+ .filter(r => r.status.editorConnected && r.status.postId);
305
+
306
+ if (connected.length === 1) {
307
+ const { name, client, status } = connected[0];
308
+ if (args?.postId != null) {
309
+ const numPid = Number(args.postId);
310
+ const isNum = Number.isFinite(numPid) && numPid > 0;
311
+ if (!isNum || numPid !== Number(status.postId)) {
312
+ // postId 不一致またはスラッグ: エディタは別記事を開いている → site 必須エラー
313
+ const connNames = allConns.map(c => c.name).join(', ');
314
+ return {
315
+ mode: 'error',
316
+ message: `複数サイトが登録されています。Headless モードでは site の指定が必須です。\n利用可能: ${connNames}`,
317
+ };
318
+ }
319
+ }
320
+
321
+ statusCache.set(name, client, status.postId);
322
+ return { mode: 'editor', postId: status.postId, client, siteName: name };
323
+ }
324
+
325
+ if (connected.length > 1) {
326
+ const list = connected.map(c =>
327
+ ` ${c.name}: postId ${c.status.postId} (${c.client.wpUrl})`
328
+ ).join('\n');
329
+ return {
330
+ mode: 'error',
331
+ message: `複数のエディタが接続中です。site パラメータで接続先を指定してください。\n\n接続中:\n${list}\n\n例: get_article_structure({ site: "${connected[0].name}" })`
332
+ };
333
+ }
334
+
335
+ // どれも未接続 + postId 指定あり → Headless だが site 未指定
336
+ if (args?.postId != null) {
337
+ const connNames = allConns.map(c => c.name).join(', ');
338
+ return {
339
+ mode: 'error',
340
+ message: `複数サイトが登録されています。Headless モードでは site の指定が必須です。\n利用可能: ${connNames}`,
341
+ };
342
+ }
343
+
344
+ // どれも未接続 + postId なし
345
+ return {
346
+ mode: 'error',
347
+ message: 'エディタ未接続です。postId を指定して Headless モードを使用するか、エディタで記事を開いてください。\n接続一覧の確認: list_connections()'
348
+ };
349
+ }
350
+
351
+ /**
352
+ * postId を解決する(数値はそのまま、文字列は slug として検索)
353
+ * slug 解決後にエディタ競合チェックも実施
354
+ */
355
+ async function resolvePostId(rawPostId, client, { skipConflictCheck = false } = {}) {
356
+ if (rawPostId == null) return { postId: null };
357
+
358
+ // 数値判定("" → 0 を除外するため num > 0)
359
+ const num = Number(rawPostId);
360
+ if (Number.isFinite(num) && num > 0) return { postId: num };
361
+
362
+ // 空文字チェック(Number("") === 0 で上を通過するため、ここで明示エラー)
363
+ let slug = String(rawPostId).trim();
364
+ if (!slug) return { error: '❌ postId が空です。' };
365
+
366
+ // URL → スラッグ抽出(query/hash を除去)
367
+ if (slug.includes('/')) {
368
+ try {
369
+ const url = new URL(slug);
370
+ slug = url.pathname.replace(/\/+$/, '').split('/').pop() || '';
371
+ } catch {
372
+ // URL パース失敗 → 相対パスとして末尾を取る
373
+ slug = slug.split('?')[0].split('#')[0].replace(/\/+$/, '').split('/').pop() || '';
374
+ }
375
+ }
376
+ if (!slug) return { error: '❌ URL からスラッグを抽出できませんでした。' };
377
+
378
+ // slug → ID 解決
379
+ const { data: posts } = await client.listPosts({ slug, status: 'any' });
380
+ if (!posts || posts.length === 0) {
381
+ return { error: `❌ スラッグ "${slug}" に一致する投稿が見つかりません。` };
382
+ }
383
+ const resolvedId = posts[0].id;
384
+
385
+ // ★ エディタ競合チェック(slug 経由でも既存と同等のガードを維持)
386
+ if (!skipConflictCheck) {
387
+ const status = await client.getStatus(resolvedId);
388
+ if (status.editorConnected &&
389
+ (resolvedId === Number(status.postId) || status.checkedEditorConnected)) {
390
+ return {
391
+ error: `❌ postId ${resolvedId}(${slug})はエディタで開かれています。postId を省略してエディタ経由で操作するか、エディタを閉じてから Headless モードを使用してください。`
392
+ };
393
+ }
394
+ }
395
+
396
+ return { postId: resolvedId };
397
+ }
398
+
56
399
  // postId パラメータ定義(Headless 対応ツール共通)
57
400
  const postIdParam = {
58
- type: "number",
59
- description: "投稿ID(エディタ未接続時は必須)",
401
+ oneOf: [
402
+ { type: "number" },
403
+ { type: "string" }
404
+ ],
405
+ description: "投稿ID(数値)またはスラッグ(文字列, URLパス末尾。例: 'madear')。URLも可(自動抽出)。スラッグ指定時は自動的にIDを解決。エディタ未接続時は必須。",
60
406
  };
61
407
 
62
408
  // site パラメータ定義(マルチ接続共通)
63
409
  const siteParam = {
64
410
  type: "string",
65
- description: "接続名(複数サイト/ユーザー設定時に指定。省略でデフォルト接続)",
411
+ description: "接続名(複数サイト/ユーザー設定時に指定。省略でデフォルト接続。Headless + 複数サイト時は必須)",
412
+ };
413
+
414
+ // ========================================
415
+ // ターゲット / インサート 共通スキーマ & ヘルパー
416
+ // ========================================
417
+
418
+ const targetSchema = {
419
+ type: "object",
420
+ properties: {
421
+ selected: {
422
+ type: "boolean",
423
+ description: "Set true to target the block currently selected by the user in WordPress editor. Cannot be used in Headless mode.",
424
+ },
425
+ index: {
426
+ type: "number",
427
+ description: "Single block index (0-based flattened position including nested blocks).",
428
+ },
429
+ indices: {
430
+ type: "array",
431
+ items: { type: "number" },
432
+ description: "Multiple block indices (0-based flattened).",
433
+ },
434
+ range: {
435
+ type: "object",
436
+ properties: {
437
+ start: { type: "number", description: "Start index (inclusive)." },
438
+ end: { type: "number", description: "End index (inclusive)." },
439
+ },
440
+ required: ["start", "end"],
441
+ description: "Contiguous range of block indices.",
442
+ },
443
+ section: {
444
+ type: "string",
445
+ description: "Target section by heading text (partial match). Combines with blockType/contains.",
446
+ },
447
+ blockType: {
448
+ type: "string",
449
+ description: "Filter by block type (e.g. core/table). Combines with section/contains.",
450
+ },
451
+ nth: {
452
+ type: "number",
453
+ description: "N-th block of the specified blockType (0-based). Use with blockType.",
454
+ },
455
+ contains: {
456
+ type: "string",
457
+ description: "Target blocks containing this text.",
458
+ },
459
+ heading: {
460
+ type: "object",
461
+ properties: {
462
+ level: { type: "number", description: "Heading level (2, 3, 4)." },
463
+ contains: { type: "string", description: "Heading text to match (partial)." },
464
+ },
465
+ required: ["level", "contains"],
466
+ description: "Section selection by heading level and text. Selects from the heading to the next same-level heading.",
467
+ },
468
+ },
469
+ description: "Block targeting criteria. Use one primary selector (selected/index/indices/range/heading) optionally combined with filters (blockType/contains/section).",
470
+ };
471
+
472
+ const targetSchemaNoSelected = {
473
+ ...targetSchema,
474
+ properties: { ...targetSchema.properties },
475
+ };
476
+ delete targetSchemaNoSelected.properties.selected;
477
+ targetSchemaNoSelected.description = "Block targeting criteria. Use one primary selector (index/range/heading) optionally combined with filters (blockType/contains/section).";
478
+
479
+ const insertSchema = {
480
+ type: "object",
481
+ properties: {
482
+ position: {
483
+ type: ["number", "string"],
484
+ description: "Insert position. 'before', 'after' (default), or block index number. Only with newHTML.",
485
+ },
486
+ },
487
+ description: "Insert mode: keep existing blocks and add newHTML as new blocks. Presence of this parameter enables insert mode.",
66
488
  };
67
489
 
490
+ /**
491
+ * 新形式の target オブジェクトを下流用のフラット形式に変換する。
492
+ * @param {object} target - 新形式 target (e.g. { selected: true }, { index: 3 })
493
+ * @returns {object} フラット形式 (e.g. { target: "selected" }, { index: 3 })
494
+ * @throws {Error} primary selector が複数指定された場合
495
+ */
496
+ function normalizeTarget(target) {
497
+ if (!target || typeof target !== "object") return {};
498
+
499
+ // --- 排他バリデーション ---
500
+ const primaries = [
501
+ target.selected && 'selected',
502
+ target.index !== undefined && 'index',
503
+ target.indices && 'indices',
504
+ target.range && 'range',
505
+ target.heading && 'heading',
506
+ ].filter(Boolean);
507
+ if (primaries.length > 1) {
508
+ throw new Error(`target に複数の primary selector が指定されています: ${primaries.join(', ')}。1つだけ指定してください。`);
509
+ }
510
+
511
+ const result = {};
512
+ if (target.selected) result.target = "selected";
513
+ if (target.index !== undefined) result.index = target.index;
514
+ if (target.indices) result.indices = target.indices;
515
+ if (target.range) {
516
+ const s = target.range.start;
517
+ const e = target.range.end;
518
+ if (s === undefined || s === null || e === undefined || e === null ||
519
+ typeof s !== "number" || typeof e !== "number" ||
520
+ !Number.isInteger(s) || !Number.isInteger(e) || s < 0 || e < 0) {
521
+ throw new Error("range.start と range.end は 0 以上の整数で両方指定してください。");
522
+ }
523
+ result.startIndex = s;
524
+ result.endIndex = e;
525
+ }
526
+ if (target.section) result.section = target.section;
527
+ if (target.blockType) result.blockType = target.blockType;
528
+ if (target.nth !== undefined) result.typeIndex = target.nth;
529
+ if (target.contains) result.contains = target.contains;
530
+ if (target.heading) {
531
+ const lv = target.heading.level;
532
+ const ct = target.heading.contains;
533
+ if (lv === undefined || typeof lv !== "number" || !ct || typeof ct !== "string") {
534
+ throw new Error("heading には level(数値)と contains(非空文字列)の両方が必要です。");
535
+ }
536
+ result.headingLevel = lv;
537
+ result.headingContains = ct;
538
+ }
539
+ return result;
540
+ }
541
+
542
+ /**
543
+ * 新形式の insert オブジェクトを下流用のフラット形式に変換する。
544
+ * @param {object} insert - 新形式 insert (e.g. { position: "after" })
545
+ * @returns {object} フラット形式 { insertOnly, insertPosition }
546
+ * @throws {Error} position の値が不正な場合
547
+ */
548
+ function normalizeInsert(insert) {
549
+ if (!insert) return { insertOnly: false };
550
+ const pos = insert.position ?? "after";
551
+ if (pos !== "before" && pos !== "after" && typeof pos !== "number") {
552
+ throw new Error(`insert.position の値が不正です: ${pos}。'before', 'after', または数値を指定してください。`);
553
+ }
554
+ if (typeof pos === "number" && (!Number.isInteger(pos) || pos < 0)) {
555
+ throw new Error(`insert.position は 0 以上の整数を指定してください: ${pos}`);
556
+ }
557
+ return { insertOnly: true, insertPosition: pos };
558
+ }
559
+
68
560
  // フィードバック送信ヘルパー
69
561
  const FEEDBACK_URL = process.env.FRIDAY_FEEDBACK_URL || '';
70
562
  async function sendFeedback(data) {
@@ -73,7 +565,7 @@ async function sendFeedback(data) {
73
565
  await fetch(FEEDBACK_URL, {
74
566
  method: 'POST',
75
567
  headers: { 'Content-Type': 'application/json' },
76
- body: JSON.stringify({ ...data, version: '2.0.0' }),
568
+ body: JSON.stringify({ ...data, version: SERVER_VERSION }),
77
569
  });
78
570
  } catch (_) {
79
571
  // フィードバック送信失敗は無視(本体の動作に影響させない)
@@ -104,7 +596,7 @@ function timeoutResponse(toolName, client, site) {
104
596
  // MCPサーバーの定義
105
597
  const server = new Server({
106
598
  name: "friday-mcp-v2",
107
- version: "2.0.0",
599
+ version: SERVER_VERSION,
108
600
  }, {
109
601
  capabilities: {
110
602
  tools: {},
@@ -163,39 +655,9 @@ const tools = [
163
655
  type: "object",
164
656
  properties: {
165
657
  site: siteParam,
166
- index: {
167
- type: "number",
168
- description: "Block index (0-based, flattened including nested blocks)",
169
- },
170
- blockType: {
171
- type: "string",
172
- description: "Block type (e.g. core/table)",
173
- },
174
- typeIndex: {
175
- type: "number",
176
- description: "N-th block of the type (0-based)",
177
- },
178
- contains: {
179
- type: "string",
180
- description: "Search text in block content",
181
- },
182
- startIndex: {
183
- type: "number",
184
- description: "Range selection start",
185
- },
186
- endIndex: {
187
- type: "number",
188
- description: "Range selection end",
189
- },
190
- headingLevel: {
191
- type: "number",
192
- description: "Heading level for section selection (2, 3, 4)",
193
- },
194
- headingContains: {
195
- type: "string",
196
- description: "Heading text for section selection",
197
- },
658
+ target: targetSchemaNoSelected,
198
659
  },
660
+ required: ["target"],
199
661
  },
200
662
  },
201
663
  {
@@ -214,7 +676,7 @@ const tools = [
214
676
  },
215
677
  {
216
678
  name: "move_block",
217
- description: "Move a block to a different position. Use from/to for top-level blocks, or fromFlat/toFlat for any block (including nested).",
679
+ description: "Move block(s) to a different position. Use from/to for top-level blocks, or fromFlat/toFlat for any block (including nested). Use count to move consecutive blocks.",
218
680
  inputSchema: {
219
681
  type: "object",
220
682
  properties: {
@@ -224,6 +686,7 @@ const tools = [
224
686
  to: { type: "number", description: "Target position (0-based, can be length for end)" },
225
687
  fromFlat: { type: "number", description: "Source flattened block index (0-based, supports nested blocks)" },
226
688
  toFlat: { type: "number", description: "Target flattened position (pre-move basis, can be blockCount for end)" },
689
+ count: { type: "integer", description: "Number of consecutive blocks to move (default: 1)" },
227
690
  },
228
691
  },
229
692
  },
@@ -303,13 +766,14 @@ const tools = [
303
766
  },
304
767
  {
305
768
  name: "list_posts",
306
- description: "Search and list WordPress posts. Use to find postId for Headless mode operations. Default returns only published posts; use status='any' to include drafts. Search matches both title and content; use short single keywords for best results.",
769
+ description: "Search and list WordPress posts. Use to find postId for other tools. For exact lookup, use 'slug' (from URL path e.g. 'my-post' from example.com/my-post/); slug auto-sets status='any' and returns exactly 1 result. For broad search, use 'search' keyword. slug and search are mutually exclusive.",
307
770
  inputSchema: {
308
771
  type: "object",
309
772
  properties: {
310
773
  site: siteParam,
311
- search: { type: "string", description: "Search keyword (title/content)" },
312
- status: { type: "string", enum: ["publish", "draft", "pending", "private", "any"], description: "Filter by status (default: publish)" },
774
+ slug: { type: "string", description: "Post slug for exact match (from URL path e.g. 'comparison' from example.com/comparison). Mutually exclusive with search." },
775
+ search: { type: "string", description: "Search keyword (title/content). Mutually exclusive with slug." },
776
+ status: { type: "string", enum: ["publish", "draft", "pending", "private", "any"], description: "Filter by status (default: publish; auto-set to 'any' when slug is specified)" },
313
777
  categories: { type: "array", items: { type: "number" }, description: "Category IDs to filter by" },
314
778
  tags: { type: "array", items: { type: "number" }, description: "Tag IDs to filter by" },
315
779
  per_page: { type: "integer", description: "Results per page (1-100, default: 10)", minimum: 1, maximum: 100 },
@@ -339,17 +803,15 @@ const tools = [
339
803
  },
340
804
  {
341
805
  name: "insert_block",
342
- description: "Insert a new block at the specified position. If index is omitted, inserts at the end.",
806
+ description: "Insert Gutenberg HTML at the specified position. If index is omitted, appends to end. HTML can be provided directly via rawHTML or loaded from a local file via filePath.",
343
807
  inputSchema: {
344
808
  type: "object",
345
809
  properties: {
346
810
  postId: postIdParam,
347
811
  site: siteParam,
348
- blockType: { type: "string", description: "Block type (e.g. core/paragraph, core/heading, core/list)" },
349
- content: { type: "string", description: "Block content (HTML or plain text depending on block type)" },
812
+ rawHTML: { type: "string", description: "Gutenberg markup HTML to insert. Mutually exclusive with filePath." },
813
+ filePath: { type: "string", description: "Absolute path to a local file containing Gutenberg HTML. Read as UTF-8. Mutually exclusive with rawHTML." },
350
814
  index: { type: "number", description: "Insert position (0-based flattened index). If omitted, appends to end." },
351
- blocks: { type: "array", items: { type: "object", properties: { blockType: { type: "string" }, content: { type: "string" } }, required: ["blockType", "content"] }, description: "Multiple blocks to insert. Cannot be used with blockType/content." },
352
- rawHTML: { type: "string", description: "Gutenberg markup HTML to insert directly. Cannot be used with blockType/content or blocks." },
353
815
  },
354
816
  },
355
817
  },
@@ -371,49 +833,9 @@ const tools = [
371
833
  properties: {
372
834
  postId: postIdParam,
373
835
  site: siteParam,
374
- target: {
375
- type: "string",
376
- enum: ["selected"],
377
- description: "Use 'selected' to target the block currently selected by the user in WordPress editor.",
378
- },
379
- index: {
380
- type: "number",
381
- description: "Single block index (0-based flattened).",
382
- },
383
- indices: {
384
- type: "array",
385
- items: { type: "number" },
386
- description: "Multiple block indices (0-based flattened).",
387
- },
388
- startIndex: {
389
- type: "number",
390
- description: "Range selection start index.",
391
- },
392
- endIndex: {
393
- type: "number",
394
- description: "Range selection end index.",
395
- },
396
- section: {
397
- type: "string",
398
- description: "Target section by heading text (partial match). Combines with blockType.",
399
- },
400
- blockType: {
401
- type: "string",
402
- description: "Filter by block type (e.g. core/table). Combines with section.",
403
- },
404
- typeIndex: {
405
- type: "number",
406
- description: "N-th block of the specified blockType (0-based). Use with blockType.",
407
- },
408
- headingLevel: {
409
- type: "number",
410
- description: "Heading level for section selection (2, 3, 4). Use with headingContains.",
411
- },
412
- headingContains: {
413
- type: "string",
414
- description: "Heading text for section selection. Use with headingLevel.",
415
- },
836
+ target: targetSchema,
416
837
  },
838
+ required: ["target"],
417
839
  },
418
840
  },
419
841
  {
@@ -424,52 +846,7 @@ const tools = [
424
846
  properties: {
425
847
  postId: postIdParam,
426
848
  site: siteParam,
427
- target: {
428
- type: "string",
429
- enum: ["selected"],
430
- description: "Use 'selected' to target the block currently selected by the user in WordPress editor.",
431
- },
432
- index: {
433
- type: "number",
434
- description: "Single block index (0-based flattened).",
435
- },
436
- indices: {
437
- type: "array",
438
- items: { type: "number" },
439
- description: "Multiple block indices (0-based flattened).",
440
- },
441
- startIndex: {
442
- type: "number",
443
- description: "Range selection start index.",
444
- },
445
- endIndex: {
446
- type: "number",
447
- description: "Range selection end index.",
448
- },
449
- section: {
450
- type: "string",
451
- description: "Target section by heading text (partial match). Combines with blockType/contains.",
452
- },
453
- blockType: {
454
- type: "string",
455
- description: "Filter by block type (e.g. core/table). Combines with section/contains.",
456
- },
457
- typeIndex: {
458
- type: "number",
459
- description: "N-th block of the specified blockType (0-based). Use with blockType.",
460
- },
461
- contains: {
462
- type: "string",
463
- description: "Target blocks containing this text.",
464
- },
465
- headingLevel: {
466
- type: "number",
467
- description: "Heading level for section selection (2, 3, 4). Use with headingContains.",
468
- },
469
- headingContains: {
470
- type: "string",
471
- description: "Heading text for section selection. Use with headingLevel.",
472
- },
849
+ target: targetSchema,
473
850
  replacements: {
474
851
  type: "array",
475
852
  items: {
@@ -485,7 +862,11 @@ const tools = [
485
862
  },
486
863
  newHTML: {
487
864
  type: "string",
488
- description: "Full HTML replacement (Gutenberg markup). Only for contiguous targets (index/startIndex+endIndex/target:selected).",
865
+ description: "Full HTML replacement (Gutenberg markup). Only for contiguous targets. Mutually exclusive with filePath.",
866
+ },
867
+ filePath: {
868
+ type: "string",
869
+ description: "Absolute path to a local file containing Gutenberg HTML. Read as UTF-8. Mutually exclusive with newHTML. Cannot be used with replacements or attributeUpdates.",
489
870
  },
490
871
  attributeUpdates: {
491
872
  type: "object",
@@ -495,24 +876,18 @@ const tools = [
495
876
  },
496
877
  description: "Direct attribute updates via updateBlockAttributes API.",
497
878
  },
498
- insertOnly: {
499
- type: "boolean",
500
- description: "Keep existing blocks, insert newHTML as new blocks. Only with newHTML.",
501
- },
502
- insertPosition: {
503
- type: ["number", "string"],
504
- description: "Insert position when insertOnly=true. 'before', 'after' (default), or number.",
505
- },
879
+ insert: insertSchema,
506
880
  dryRun: {
507
881
  type: "boolean",
508
882
  description: "Preview changes without applying.",
509
883
  },
510
884
  },
885
+ required: ["target"],
511
886
  },
512
887
  },
513
888
  {
514
889
  name: "table_operations",
515
- description: "Perform structured operations on core/table blocks (get structure, update cells, add/delete rows/columns, style cells/rows).",
890
+ description: "Perform structured operations on core/table blocks (get structure, update/move cells/rows/columns, add/delete rows/columns).",
516
891
  inputSchema: {
517
892
  type: "object",
518
893
  properties: {
@@ -524,7 +899,7 @@ const tools = [
524
899
  },
525
900
  action: {
526
901
  type: "string",
527
- enum: ["get_structure", "update_cell", "add_row", "delete_row", "add_column", "delete_column", "style_cell", "style_row"],
902
+ enum: ["get_structure", "update_cell", "add_row", "delete_row", "add_column", "delete_column", "move_row", "move_column", "update_row", "update_column"],
528
903
  description: "Operation type.",
529
904
  },
530
905
  row: {
@@ -541,28 +916,33 @@ const tools = [
541
916
  },
542
917
  position: {
543
918
  type: "number",
544
- description: "Insert position for add_row/add_column. Omit for end.",
919
+ description: "Position index. add_row/add_column: insert-before index (omit for end). move_row/move_column: destination index after move (required).",
545
920
  },
546
921
  cells: {
547
922
  type: "array",
548
923
  items: { type: "string" },
549
- description: "Cell contents for new row (add_row). Omit for empty cells.",
550
- },
551
- style: {
552
- type: "object",
553
- properties: {
554
- backgroundColor: { type: "string", description: "Background color (e.g. #ff0000)" },
555
- color: { type: "string", description: "Text color" },
556
- fontWeight: { type: "string", description: "Font weight (bold, normal)" },
557
- textAlign: { type: "string", description: "Text alignment (left, center, right)" },
558
- fontSize: { type: "string", description: "Font size (e.g. 14px)" },
559
- },
560
- description: "Style for style_cell/style_row.",
924
+ description: "Cell contents array. add_row: new row cells. add_column: initial column values. update_row/update_column: replacement values. Omit for empty cells.",
561
925
  },
562
926
  },
563
927
  required: ["index", "action"],
564
928
  },
565
929
  },
930
+ {
931
+ name: "open_in_browser",
932
+ description: "Open post in the OS default browser. Opens editor (wp-admin) or front page. Uses the user's default browser profile (logged-in session).",
933
+ inputSchema: {
934
+ type: "object",
935
+ properties: {
936
+ postId: postIdParam,
937
+ site: siteParam,
938
+ target: {
939
+ type: "string",
940
+ enum: ["editor", "front"],
941
+ description: "Open target. 'editor' = wp-admin edit screen, 'front' = public permalink page. Default: 'editor'.",
942
+ },
943
+ },
944
+ },
945
+ },
566
946
  {
567
947
  name: "list_connections",
568
948
  description: "List available site/user connections.",
@@ -601,20 +981,327 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
601
981
  return { tools };
602
982
  });
603
983
 
984
+ /**
985
+ * update_blocks ツールの共通ハンドラ(insert_block からの委譲にも対応)
986
+ * @param {object} args - ツール引数
987
+ * @param {string} toolName - 呼び出し元ツール名(レスポンス表示用)
988
+ */
989
+ async function handleUpdateBlocksTool(args, toolName) {
990
+ const name = toolName || "update_blocks";
991
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
992
+ if (mode === 'error') {
993
+ return errorResponse(name, message, args?.site);
994
+ }
995
+
996
+ // slug → postId 解決(headless モード時のみ)
997
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
998
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
999
+ const postId = _resolved.postId ?? _postId;
1000
+
1001
+ // --- 旧フラットパラメータの混在を検出(後方互換なし方針) ---
1002
+ const legacyKeys = ['insertOnly', 'insertPosition', 'startIndex', 'endIndex',
1003
+ 'headingLevel', 'headingContains', 'typeIndex'];
1004
+ const foundLegacy = legacyKeys.filter(k => args?.[k] !== undefined);
1005
+ if (foundLegacy.length > 0) {
1006
+ return {
1007
+ content: [{ type: "text", text: `❌ 旧パラメータ形式は廃止されました: ${foundLegacy.join(', ')}。target / insert オブジェクトを使用してください。` }],
1008
+ isError: true,
1009
+ };
1010
+ }
1011
+
1012
+ // --- normalizeTarget / normalizeInsert ---
1013
+ let tp, ins;
1014
+ try {
1015
+ tp = normalizeTarget(args?.target);
1016
+ ins = normalizeInsert(args?.insert);
1017
+ } catch (e) {
1018
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
1019
+ }
1020
+
1021
+ const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex,
1022
+ contains, headingLevel, headingContains } = tp;
1023
+ const { insertOnly, insertPosition } = ins;
1024
+ const { replacements, attributeUpdates, dryRun,
1025
+ _fromInsertBlock, appendToEnd } = (args || {});
1026
+ let { newHTML } = (args || {});
1027
+
1028
+ // --- filePath → newHTML 解決(update_blocks 直接呼び出し時) ---
1029
+ if (args?.filePath) {
1030
+ if (newHTML) {
1031
+ return { content: [{ type: "text", text: "❌ newHTML と filePath は同時に指定できません。どちらか一方を指定してください。" }], isError: true };
1032
+ }
1033
+ if (replacements && replacements.length > 0) {
1034
+ return { content: [{ type: "text", text: "❌ filePath は replacements と併用できません。" }], isError: true };
1035
+ }
1036
+ if (attributeUpdates) {
1037
+ return { content: [{ type: "text", text: "❌ filePath は attributeUpdates と併用できません。" }], isError: true };
1038
+ }
1039
+ try {
1040
+ newHTML = readHTMLFromFile(args.filePath).html;
1041
+ } catch (e) {
1042
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
1043
+ }
1044
+ }
1045
+
1046
+ // --- insert 排他チェック ---
1047
+ if (insertOnly && (replacements?.length > 0 || attributeUpdates)) {
1048
+ return {
1049
+ content: [{ type: "text", text: "❌ insert は newHTML 専用です。replacements や attributeUpdates と併用できません。" }],
1050
+ isError: true,
1051
+ };
1052
+ }
1053
+ if (insertOnly && !newHTML) {
1054
+ return {
1055
+ content: [{ type: "text", text: "❌ insert には newHTML が必要です。" }],
1056
+ isError: true,
1057
+ };
1058
+ }
1059
+
1060
+ // Headless モードで target: "selected" はエラー
1061
+ if (mode === 'headless' && target === 'selected') {
1062
+ return {
1063
+ content: [{ type: "text", text: "❌ target:'selected' はエディタ接続時のみ使用可能です。index を指定してください。" }],
1064
+ isError: true,
1065
+ };
1066
+ }
1067
+
1068
+ // --- ターゲットチェック(appendToEnd 時は不要) ---
1069
+ const hasTarget = target || index !== undefined || indices ||
1070
+ startIndex !== undefined || section || blockType || contains ||
1071
+ (headingLevel && headingContains);
1072
+ if (!hasTarget && !(appendToEnd && insertOnly)) {
1073
+ return {
1074
+ content: [{ type: "text", text: "❌ ターゲットが指定されていません。\ntarget, index, indices, section, blockType, contains 等のいずれかを指定してください。" }],
1075
+ isError: true,
1076
+ };
1077
+ }
1078
+
1079
+ const hasUpdate = (replacements && replacements.length > 0) || newHTML || attributeUpdates;
1080
+ if (!hasUpdate) {
1081
+ return {
1082
+ content: [{ type: "text", text: "❌ 変更内容が指定されていません。\nreplacements, newHTML, attributeUpdates のいずれかを指定してください。" }],
1083
+ isError: true,
1084
+ };
1085
+ }
1086
+
1087
+ // --- insertOnly 非推奨警告(外部呼び出し時のみ) ---
1088
+ let deprecationWarning = '';
1089
+ if (insertOnly && !_fromInsertBlock) {
1090
+ deprecationWarning = '\n⚠️ update_blocks の insertOnly は非推奨です。insert_block ツールを使用してください。';
1091
+ console.error('[DEPRECATED] update_blocks: insertOnly は非推奨です。insert_block を使用してください。');
1092
+ }
1093
+
1094
+ // ========================================
1095
+ // Headless モード
1096
+ // ========================================
1097
+ if (mode === 'headless') {
1098
+ // appendToEnd: ターゲット不要の末尾追加
1099
+ if (appendToEnd && insertOnly && newHTML) {
1100
+ const result = await client.headlessUpdate(postId, {
1101
+ newHTML,
1102
+ insertOnly: true,
1103
+ appendToEnd: true,
1104
+ _fromInsertBlock: _fromInsertBlock || false,
1105
+ });
1106
+ const count = result.results?.[0]?.count || 1;
1107
+ return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at end${deprecationWarning}` }] };
1108
+ }
1109
+
1110
+ const result = await client.headlessUpdate(postId, {
1111
+ index, indices, startIndex, endIndex,
1112
+ section, blockType, typeIndex, contains,
1113
+ headingLevel, headingContains,
1114
+ replacements, newHTML, attributeUpdates,
1115
+ insertOnly: insertOnly || false,
1116
+ insertPosition,
1117
+ dryRun: dryRun || false,
1118
+ _fromInsertBlock: _fromInsertBlock || false,
1119
+ });
1120
+
1121
+ if (result.dryRun && result.results) {
1122
+ const previewText = result.results
1123
+ .map(r => {
1124
+ const label = r.index !== undefined ? `[${r.index}]` :
1125
+ r.indices ? `[${r.indices.join(', ')}]` : '[?]';
1126
+ return ` ${label}: ${r.preview ? 'プレビューあり' : 'N/A'}`;
1127
+ })
1128
+ .join('\n');
1129
+ return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText}${deprecationWarning}` }] };
1130
+ }
1131
+
1132
+ if (result.results) {
1133
+ const successCount = result.results.filter(r => r.success).length;
1134
+ const failCount = result.results.filter(r => !r.success).length;
1135
+ return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}` }] };
1136
+ }
1137
+
1138
+ return { content: [{ type: "text", text: `✅ 更新完了${deprecationWarning}` }] };
1139
+ }
1140
+
1141
+ // ========================================
1142
+ // Editor モード
1143
+ // ========================================
1144
+
1145
+ // appendToEnd: ターゲット不要の末尾追加
1146
+ if (appendToEnd && insertOnly && newHTML) {
1147
+ const result = await client.sendEditorCommand("update_blocks", {
1148
+ newHTML,
1149
+ insertOnly: true,
1150
+ appendToEnd: true,
1151
+ }, 10000);
1152
+ if (!result) return timeoutResponse(name, client, args?.site);
1153
+ if (!result.success) return errorResponse(name, result.error, args?.site);
1154
+ const count = result.inserted || 1;
1155
+ return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at end${deprecationWarning}` }] };
1156
+ }
1157
+
1158
+ // target: "selected" の場合 → エディタ状態から選択情報を取得してMCP側で処理
1159
+ if (target === "selected") {
1160
+ const state = await client.getEditorState();
1161
+ const sel = state.selectedBlock;
1162
+
1163
+ if (!sel || (!sel.blockId && !sel.blockIds)) {
1164
+ return {
1165
+ content: [{ type: "text", text: "❌ ブロックが選択されていません。" }],
1166
+ isError: true,
1167
+ };
1168
+ }
1169
+
1170
+ const blockIndices = sel.isMultiSelect ? sel.blockIndices : [sel.blockIndex];
1171
+
1172
+ // replacementsの場合、MCP側でカーソル選択ピンポイント差分を適用
1173
+ if (replacements && replacements.length > 0) {
1174
+ if (!sel.blockHTML) {
1175
+ return {
1176
+ content: [{ type: "text", text: "❌ 選択中ブロックのHTML情報がありません。" }],
1177
+ isError: true,
1178
+ };
1179
+ }
1180
+ let finalHTML = sel.blockHTML;
1181
+ const hasTextSelection = sel.textSelection && sel.textSelection.text;
1182
+ for (const replacement of replacements) {
1183
+ if (!replacement.old || !replacement.new) continue;
1184
+ if (hasTextSelection && sel.textSelection) {
1185
+ const { text: selText, context } = sel.textSelection;
1186
+ const searchPattern = context.before + selText + context.after;
1187
+ if (finalHTML.includes(searchPattern)) {
1188
+ const replacedPattern = context.before + replacement.new + context.after;
1189
+ finalHTML = finalHTML.replace(searchPattern, replacedPattern);
1190
+ } else {
1191
+ finalHTML = finalHTML.replace(new RegExp(escapeRegExp(replacement.old), 'g'), replacement.new);
1192
+ }
1193
+ } else {
1194
+ finalHTML = finalHTML.replace(new RegExp(escapeRegExp(replacement.old), 'g'), replacement.new);
1195
+ }
1196
+ }
1197
+ const result = await client.sendEditorCommand("update_blocks", {
1198
+ mode: "html_replace_by_index",
1199
+ blockIndices,
1200
+ newHTML: finalHTML,
1201
+ insertOnly: insertOnly || false,
1202
+ insertPosition,
1203
+ }, 10000);
1204
+ if (!result)
1205
+ return timeoutResponse(name, client, args?.site);
1206
+ if (!result.success)
1207
+ return errorResponse(name, result.error, args?.site);
1208
+ return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}${deprecationWarning}` }] };
1209
+ }
1210
+
1211
+ // newHTMLの場合
1212
+ if (newHTML) {
1213
+ const result = await client.sendEditorCommand("update_blocks", {
1214
+ mode: "html_replace_by_index",
1215
+ blockIndices,
1216
+ newHTML,
1217
+ insertOnly: insertOnly || false,
1218
+ insertPosition,
1219
+ }, 10000);
1220
+ if (!result)
1221
+ return timeoutResponse(name, client, args?.site);
1222
+ if (!result.success)
1223
+ return errorResponse(name, result.error, args?.site);
1224
+ return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}` }] };
1225
+ }
1226
+
1227
+ // attributeUpdatesの場合
1228
+ const result = await client.sendEditorCommand("update_blocks", {
1229
+ mode: "attribute_update_by_index",
1230
+ blockIndices,
1231
+ attributeUpdates,
1232
+ dryRun: dryRun || false,
1233
+ }, 10000);
1234
+ if (!result)
1235
+ return timeoutResponse(name, client, args?.site);
1236
+ if (!result.success)
1237
+ return errorResponse(name, result.error, args?.site);
1238
+ return { content: [{ type: "text", text: `✅ 属性更新完了${deprecationWarning}` }] };
1239
+ }
1240
+
1241
+ // その他のターゲット → 全てWP側で解決&実行
1242
+ const maxWait = (section || blockType || contains) ? 15000 : 10000;
1243
+ const result = await client.sendEditorCommand("update_blocks", {
1244
+ index, indices, startIndex, endIndex,
1245
+ section, blockType, typeIndex, contains,
1246
+ headingLevel, headingContains,
1247
+ replacements, newHTML, attributeUpdates,
1248
+ insertOnly: insertOnly || false,
1249
+ insertPosition,
1250
+ dryRun: dryRun || false,
1251
+ _fromInsertBlock: _fromInsertBlock || false,
1252
+ }, maxWait);
1253
+ if (!result)
1254
+ return timeoutResponse(name, client, args?.site);
1255
+ if (!result.success)
1256
+ return errorResponse(name, result.error, args?.site);
1257
+
1258
+ // 結果フォーマット
1259
+ if (dryRun && result.results) {
1260
+ const previewText = result.results
1261
+ .filter(r => r.preview)
1262
+ .map(r => {
1263
+ if (Array.isArray(r.preview)) {
1264
+ return ` [${r.index}]\n${r.preview.map(p => ` "${p.old}" → "${p.new}" (${p.count}箇所)`).join('\n')}`;
1265
+ } else if (r.preview?.willUpdate) {
1266
+ return ` [${r.index}]: 属性更新予定 ${JSON.stringify(r.preview.willUpdate)}`;
1267
+ } else if (r.preview?.skipped) {
1268
+ return ` [${r.index}]: スキップ (${r.preview.skipped})`;
1269
+ }
1270
+ return ` [${r.index}]: プレビュー不可`;
1271
+ })
1272
+ .join('\n');
1273
+ return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText || "(対象なし)"}${deprecationWarning}` }] };
1274
+ }
1275
+
1276
+ if (result.results) {
1277
+ const successCount = result.results.filter(r => r.success && !r.skipped).length;
1278
+ const skipCount = result.results.filter(r => r.skipped).length;
1279
+ const failCount = result.results.filter(r => !r.success).length;
1280
+ const detailText = result.results.filter(r => r.success && r.replaceCount > 0).map(r => ` [${r.index}]: ${r.replaceCount}箇所`).join('\n');
1281
+ return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}` }] };
1282
+ }
1283
+
1284
+ return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}` }] };
1285
+ }
1286
+
604
1287
  // ツール実行のハンドラ
605
1288
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
606
1289
  const { name, arguments: args } = request.params;
607
1290
  try {
608
1291
  switch (name) {
609
1292
  case "get_article_structure": {
610
- const { mode, postId, message, client } = await resolveMode(args);
1293
+ const { mode, postId: _postId, message, client, siteName } = await resolveMode(args);
611
1294
  if (mode === 'error') {
612
1295
  return errorResponse(name, message, args?.site);
613
1296
  }
1297
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
1298
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
1299
+ const postId = _resolved.postId ?? _postId;
614
1300
 
615
1301
  const { blockType, section, headingLevel, expand, contains, full, limit, offset } = (args || {});
616
1302
 
617
1303
  // 使用パラメータを特定
1304
+ const siteLabel = siteName || 'default';
618
1305
  const usedParams = [];
619
1306
  if (section) usedParams.push("section");
620
1307
  if (blockType) usedParams.push("blockType");
@@ -624,7 +1311,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
624
1311
  if (full) usedParams.push("full");
625
1312
  if (limit && limit > 0) usedParams.push(`limit=${limit}`);
626
1313
  if (offset && offset > 0) usedParams.push(`offset=${offset}`);
627
- const paramLabel = usedParams.length > 0 ? usedParams.join(" + ") : "default";
1314
+ const paramLabel = usedParams.length > 0
1315
+ ? `${siteLabel}, ${usedParams.join(" + ")}`
1316
+ : siteLabel;
628
1317
 
629
1318
  // Headless / Editor でデータ取得を分岐
630
1319
  let state;
@@ -1035,13 +1724,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1035
1724
  return { content: [{ type: "text", text: `❌ select_block はエディタ接続時のみ使用可能です。` }], isError: true };
1036
1725
  }
1037
1726
 
1038
- const { index, blockType, typeIndex, contains, startIndex, endIndex, headingLevel, headingContains } = (args || {});
1039
-
1040
1727
  try {
1041
- await client.selectBlock({
1042
- index, blockType, typeIndex, contains,
1043
- startIndex, endIndex, headingLevel, headingContains
1044
- });
1728
+ const tp = normalizeTarget(args?.target);
1729
+
1730
+ // BUG-3: select_block では selected は使用不可
1731
+ if (tp.target === "selected") {
1732
+ return {
1733
+ content: [{ type: "text", text: "❌ select_block では selected は使用できません。index, blockType, range 等を指定してください。" }],
1734
+ isError: true,
1735
+ };
1736
+ }
1737
+
1738
+ // BUG-2: hasTarget チェック
1739
+ const { target, index, indices, startIndex, endIndex, section, blockType,
1740
+ typeIndex, contains, headingLevel, headingContains } = tp;
1741
+ const hasTarget = target || index !== undefined || indices ||
1742
+ startIndex !== undefined || section || blockType || contains ||
1743
+ (headingLevel && headingContains);
1744
+ if (!hasTarget) {
1745
+ return {
1746
+ content: [{ type: "text", text: "❌ ターゲットが指定されていません。index, blockType, range, heading, contains 等のいずれかを指定してください。" }],
1747
+ isError: true,
1748
+ };
1749
+ }
1750
+
1751
+ await client.selectBlock(tp);
1045
1752
 
1046
1753
  return {
1047
1754
  content: [{
@@ -1061,10 +1768,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1061
1768
  }
1062
1769
 
1063
1770
  case "delete_block": {
1064
- const { mode, postId, message, client } = await resolveMode(args);
1771
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1065
1772
  if (mode === 'error') {
1066
1773
  return errorResponse(name, message, args?.site);
1067
1774
  }
1775
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
1776
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
1777
+ const postId = _resolved.postId ?? _postId;
1068
1778
 
1069
1779
  const { index, count, indices } = args;
1070
1780
 
@@ -1119,12 +1829,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1119
1829
  }
1120
1830
 
1121
1831
  case "move_block": {
1122
- const { mode, postId, message, client } = await resolveMode(args);
1832
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1123
1833
  if (mode === 'error') {
1124
1834
  return errorResponse(name, message, args?.site);
1125
1835
  }
1836
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
1837
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
1838
+ const postId = _resolved.postId ?? _postId;
1126
1839
 
1127
1840
  const { from, to, fromFlat, toFlat } = args;
1841
+ const count = args.count ?? 1;
1842
+
1843
+ // count バリデーション
1844
+ if (!Number.isInteger(count) || count < 1) {
1845
+ return { content: [{ type: "text", text: "❌ count は1以上の整数を指定してください。" }], isError: true };
1846
+ }
1128
1847
 
1129
1848
  // 排他バリデーション
1130
1849
  const hasTopLevel = from !== undefined || to !== undefined;
@@ -1136,26 +1855,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1136
1855
  return { content: [{ type: "text", text: "❌ from/to または fromFlat/toFlat を指定してください。" }], isError: true };
1137
1856
  }
1138
1857
 
1858
+ // 移動結果のレスポンス生成ヘルパー
1859
+ const moveMsg = (moved) => {
1860
+ if (moved?.noop) return `✅ 移動不要(同じ位置)`;
1861
+ const typeLabel = moved?.types ? moved.types.join(', ') : moved?.type ?? 'unknown';
1862
+ const countLabel = (moved?.count ?? 1) > 1 ? `${moved.count}ブロック` : '';
1863
+ return countLabel
1864
+ ? `✅ ${countLabel}移動 (${typeLabel})`
1865
+ : `✅ ブロック移動 (${typeLabel})`;
1866
+ };
1867
+
1139
1868
  // fromFlat/toFlat モード(フラットインデックス移動)
1140
1869
  if (hasFlat) {
1141
1870
  if (fromFlat === undefined || toFlat === undefined) {
1142
1871
  return { content: [{ type: "text", text: "❌ fromFlat と toFlat の両方を指定してください。" }], isError: true };
1143
1872
  }
1144
- if (fromFlat === toFlat) {
1145
- return { content: [{ type: "text", text: "❌ fromFlat と toFlat が同じ位置です。" }], isError: true };
1873
+ if (fromFlat === toFlat || toFlat === fromFlat + count) {
1874
+ return { content: [{ type: "text", text: `✅ 移動不要(同じ位置)` }] };
1146
1875
  }
1147
1876
 
1148
1877
  if (mode === 'headless') {
1149
- const result = await client.headlessMoveFlat(postId, fromFlat, toFlat);
1150
- return { content: [{ type: "text", text: `✅ ブロック移動: flat[${fromFlat}] → flat[${toFlat}] (${result.moved.type})` }] };
1878
+ const result = await client.headlessMoveFlat(postId, fromFlat, toFlat, count);
1879
+ return { content: [{ type: "text", text: moveMsg(result.moved) }] };
1151
1880
  }
1152
1881
 
1153
- const result = await client.sendEditorCommand("move_block", { fromFlat, toFlat });
1882
+ const result = await client.sendEditorCommand("move_block", { fromFlat, toFlat, count });
1154
1883
  if (!result)
1155
1884
  return timeoutResponse(name, client, args?.site);
1156
1885
  if (!result.success)
1157
1886
  return errorResponse(name, result.error, args?.site);
1158
- return { content: [{ type: "text", text: `✅ ブロック移動: flat[${fromFlat}] → flat[${toFlat}] (${result.moved.type})` }] };
1887
+ return { content: [{ type: "text", text: moveMsg(result.moved) }] };
1159
1888
  }
1160
1889
 
1161
1890
  // 既存モード(from/to トップレベル)
@@ -1164,18 +1893,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1164
1893
  }
1165
1894
 
1166
1895
  if (mode === 'headless') {
1167
- const result = await client.headlessMove(postId, from, to);
1168
- return { content: [{ type: "text", text: `✅ ブロック移動: [${from}] → [${to}] (${result.moved.type})` }] };
1896
+ const result = await client.headlessMove(postId, from, to, count);
1897
+ return { content: [{ type: "text", text: moveMsg(result.moved) }] };
1169
1898
  }
1170
1899
 
1171
- const result = await client.sendEditorCommand("move_block", { from, to });
1900
+ const result = await client.sendEditorCommand("move_block", { from, to, count });
1172
1901
  if (!result)
1173
1902
  return timeoutResponse(name, client, args?.site);
1174
1903
  if (!result.success)
1175
1904
  return errorResponse(name, result.error, args?.site);
1176
- if (result.moved?.noop)
1177
- return { content: [{ type: "text", text: `✅ 移動不要(同じ位置)` }] };
1178
- return { content: [{ type: "text", text: `✅ ブロック移動: [${from}] → [${to}] (${result.moved.type})` }] };
1905
+ return { content: [{ type: "text", text: moveMsg(result.moved) }] };
1179
1906
  }
1180
1907
 
1181
1908
  case "undo": {
@@ -1211,10 +1938,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1211
1938
  }
1212
1939
 
1213
1940
  case "duplicate_block": {
1214
- const { mode, postId, message, client } = await resolveMode(args);
1941
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1215
1942
  if (mode === 'error') {
1216
1943
  return errorResponse(name, message, args?.site);
1217
1944
  }
1945
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
1946
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
1947
+ const postId = _resolved.postId ?? _postId;
1218
1948
 
1219
1949
  const { index } = args;
1220
1950
 
@@ -1235,10 +1965,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1235
1965
  }
1236
1966
 
1237
1967
  case "save_post": {
1238
- const { mode, postId, message, client } = await resolveMode(args);
1968
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1239
1969
  if (mode === 'error') {
1240
1970
  return errorResponse(name, message, args?.site);
1241
1971
  }
1972
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
1973
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
1974
+ const postId = _resolved.postId ?? _postId;
1242
1975
 
1243
1976
  if (mode === 'headless') {
1244
1977
  // Headless モードでは即 DB 書き込みのため save は不要
@@ -1254,10 +1987,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1254
1987
  }
1255
1988
 
1256
1989
  case "get_post_meta": {
1257
- const { mode, postId, message, client } = await resolveMode(args);
1990
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1258
1991
  if (mode === 'error') {
1259
1992
  return errorResponse(name, message, args?.site);
1260
1993
  }
1994
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
1995
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
1996
+ const postId = _resolved.postId ?? _postId;
1261
1997
 
1262
1998
  let m;
1263
1999
  if (mode === 'headless') {
@@ -1293,10 +2029,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1293
2029
  }
1294
2030
 
1295
2031
  case "update_post_meta": {
1296
- const { mode, postId, message, client } = await resolveMode(args);
2032
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1297
2033
  if (mode === 'error') {
1298
2034
  return errorResponse(name, message, args?.site);
1299
2035
  }
2036
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
2037
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
2038
+ const postId = _resolved.postId ?? _postId;
1300
2039
 
1301
2040
  const updateData = {};
1302
2041
  if (args.title !== undefined) updateData.title = args.title;
@@ -1330,7 +2069,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1330
2069
  } catch (e) {
1331
2070
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
1332
2071
  }
1333
- const { data: posts, total, totalPages } = await client.listPosts(args || {});
2072
+
2073
+ // slug/search の空白正規化
2074
+ const slug = args?.slug?.trim() || undefined;
2075
+ const search = args?.search?.trim() || undefined;
2076
+
2077
+ // slug と search の排他チェック
2078
+ if (slug && search) {
2079
+ return { content: [{ type: "text", text: "❌ slug と search は同時に指定できません。slug(完全一致)または search(キーワード検索)のいずれかを使用してください。" }], isError: true };
2080
+ }
2081
+
2082
+ // listArgs を構築(slug 指定時: status 未指定なら 'any' に自動設定)
2083
+ const listArgs = { ...(args || {}), slug, search };
2084
+ if (slug && !listArgs.status) {
2085
+ listArgs.status = 'any';
2086
+ }
2087
+
2088
+ const { data: posts, total, totalPages } = await client.listPosts(listArgs);
1334
2089
 
1335
2090
  if (!posts || posts.length === 0) {
1336
2091
  return { content: [{ type: "text", text: "📋 該当する投稿はありません。" }] };
@@ -1393,77 +2148,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1393
2148
  }
1394
2149
 
1395
2150
  case "insert_block": {
1396
- const { mode, postId, message, client } = await resolveMode(args);
1397
- if (mode === 'error') {
1398
- return errorResponse(name, message, args?.site);
1399
- }
1400
-
1401
- const { blockType, content, index, blocks, rawHTML } = args;
2151
+ let { rawHTML, filePath, index } = (args || {});
1402
2152
 
1403
- // 排他バリデーション
1404
- const hasLegacy = blockType !== undefined || content !== undefined;
1405
- const hasBlocks = blocks !== undefined;
1406
- const hasRawHTML = rawHTML !== undefined;
1407
- const modeCount = [hasLegacy, hasBlocks, hasRawHTML].filter(Boolean).length;
1408
- if (modeCount === 0) {
1409
- return { content: [{ type: "text", text: "❌ blockType+content、blocks、rawHTML のいずれかを指定してください。" }], isError: true };
2153
+ // 排他チェック
2154
+ if (rawHTML && filePath) {
2155
+ return { content: [{ type: "text", text: "❌ rawHTML と filePath は同時に指定できません。どちらか一方を指定してください。" }], isError: true };
1410
2156
  }
1411
- if (modeCount > 1) {
1412
- return { content: [{ type: "text", text: "❌ blockType+content、blocks、rawHTML は同時に指定できません。" }], isError: true };
1413
- }
1414
-
1415
- // 複数挿入モード (blocks / rawHTML)
1416
- if (hasBlocks || hasRawHTML) {
1417
- if (hasBlocks && (!Array.isArray(blocks) || blocks.length === 0)) {
1418
- return { content: [{ type: "text", text: "❌ blocks は1つ以上の要素を含む配列で指定してください。" }], isError: true };
1419
- }
1420
2157
 
1421
- if (mode === 'headless') {
1422
- const params = { index };
1423
- if (hasBlocks) params.blocks = blocks;
1424
- if (hasRawHTML) params.rawHTML = rawHTML;
1425
- const result = await client.headlessInsert(postId, params);
1426
- const count = result.insertedIndices ? result.insertedIndices.length : result.count || 1;
1427
- return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at ${index ?? 'end'}` }] };
1428
- }
1429
-
1430
- const cmdParams = { index };
1431
- if (hasBlocks) cmdParams.blocks = blocks;
1432
- if (hasRawHTML) cmdParams.rawHTML = rawHTML;
1433
- const result = await client.sendEditorCommand("insert_block", cmdParams);
1434
- if (!result)
1435
- return timeoutResponse(name, client, args?.site);
1436
- if (!result.success)
1437
- return errorResponse(name, result.error, args?.site);
1438
- const count = result.insertedCount || result.inserted?.length || 1;
1439
- return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at ${index ?? 'end'}` }] };
2158
+ // filePath rawHTML 解決
2159
+ if (filePath) {
2160
+ try { rawHTML = readHTMLFromFile(filePath).html; }
2161
+ catch (e) { return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true }; }
1440
2162
  }
1441
-
1442
- // 既存モード(単一 blockType + content)
1443
- if (!blockType || content === undefined) {
1444
- return { content: [{ type: "text", text: "❌ blockType と content は必須です" }], isError: true };
2163
+ if (!rawHTML) {
2164
+ return { content: [{ type: "text", text: "❌ rawHTML または filePath を指定してください。Gutenberg HTML 形式のブロックマークアップが必要です。" }], isError: true };
1445
2165
  }
1446
2166
 
1447
- if (mode === 'headless') {
1448
- const result = await client.headlessInsert(postId, { blockType, content, index });
1449
- return { content: [{ type: "text", text: `✅ ブロック挿入: ${blockType} at ${result.newIndex}` }] };
2167
+ // index バリデーション(現行 Editor 互換: 整数 >= 0)
2168
+ if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
2169
+ return { content: [{ type: "text", text: `❌ Invalid index: ${index}` }], isError: true };
1450
2170
  }
1451
2171
 
1452
- const result = await client.sendEditorCommand("insert_block", { blockType, content, index });
1453
- if (!result)
1454
- return timeoutResponse(name, client, args?.site);
1455
- if (!result.success)
1456
- return errorResponse(name, result.error, args?.site);
1457
- return { content: [{ type: "text", text: `✅ ブロック挿入: ${result.inserted.type} at ${result.inserted.index}` }] };
2172
+ // update_blocks コードパスに委譲(新形式 target/insert を使用)
2173
+ const delegatedArgs = {
2174
+ postId: args.postId,
2175
+ site: args.site,
2176
+ newHTML: rawHTML,
2177
+ insert: { position: 'before' },
2178
+ _fromInsertBlock: true,
2179
+ appendToEnd: index === undefined,
2180
+ };
2181
+ if (index !== undefined) delegatedArgs.target = { index };
2182
+ return await handleUpdateBlocksTool(delegatedArgs, name);
1458
2183
  }
1459
2184
 
1460
2185
  case "get_block_html": {
1461
- const { mode, postId, message, client } = await resolveMode(args);
2186
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1462
2187
  if (mode === 'error') {
1463
2188
  return errorResponse(name, message, args?.site);
1464
2189
  }
2190
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
2191
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
2192
+ const postId = _resolved.postId ?? _postId;
1465
2193
 
1466
- const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex, headingLevel, headingContains } = (args || {});
2194
+ let tp;
2195
+ try { tp = normalizeTarget(args?.target); }
2196
+ catch (e) { return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true }; }
2197
+
2198
+ const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex, contains, headingLevel, headingContains } = tp;
1467
2199
 
1468
2200
  if (mode === 'headless' && target === 'selected') {
1469
2201
  return {
@@ -1473,7 +2205,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1473
2205
  }
1474
2206
 
1475
2207
  const hasTarget = target || index !== undefined || indices ||
1476
- startIndex !== undefined || section || blockType ||
2208
+ startIndex !== undefined || section || blockType || contains ||
1477
2209
  (headingLevel && headingContains);
1478
2210
  if (!hasTarget) {
1479
2211
  return {
@@ -1486,12 +2218,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1486
2218
  if (mode === 'headless') {
1487
2219
  const result = await client.headlessGetBlockHtml(postId, {
1488
2220
  index, indices, startIndex, endIndex,
1489
- section, blockType, typeIndex,
2221
+ section, blockType, typeIndex, contains,
1490
2222
  headingLevel, headingContains,
1491
2223
  });
1492
2224
  blocks = result.blocks || [];
1493
2225
  } else {
1494
- const result = await client.sendEditorCommand("get_block_html", { target, index, indices, startIndex, endIndex, section, blockType, typeIndex, headingLevel, headingContains }, 10000);
2226
+ const result = await client.sendEditorCommand("get_block_html", {
2227
+ target, index, indices, startIndex, endIndex,
2228
+ section, blockType, typeIndex, contains,
2229
+ headingLevel, headingContains,
2230
+ }, 10000);
1495
2231
  if (!result) {
1496
2232
  return timeoutResponse(name, client, args?.site);
1497
2233
  }
@@ -1545,210 +2281,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1545
2281
  }
1546
2282
 
1547
2283
  case "update_blocks": {
1548
- const { mode, postId: modePostId, message, client } = await resolveMode(args);
1549
- if (mode === 'error') {
1550
- return errorResponse(name, message, args?.site);
1551
- }
1552
-
1553
- const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex, contains, headingLevel, headingContains, replacements, newHTML, attributeUpdates, insertOnly, insertPosition, dryRun } = (args || {});
1554
-
1555
- // Headless モードで target: "selected" はエラー
1556
- if (mode === 'headless' && target === 'selected') {
1557
- return {
1558
- content: [{ type: "text", text: "❌ target:'selected' はエディタ接続時のみ使用可能です。index を指定してください。" }],
1559
- isError: true,
1560
- };
1561
- }
1562
-
1563
- const hasTarget = target || index !== undefined || indices ||
1564
- startIndex !== undefined || section || blockType || contains ||
1565
- (headingLevel && headingContains);
1566
- if (!hasTarget) {
1567
- return {
1568
- content: [{ type: "text", text: "❌ ターゲットが指定されていません。\ntarget, index, indices, section, blockType, contains 等のいずれかを指定してください。" }],
1569
- isError: true,
1570
- };
1571
- }
1572
-
1573
- const hasUpdate = (replacements && replacements.length > 0) || newHTML || attributeUpdates;
1574
- if (!hasUpdate) {
1575
- return {
1576
- content: [{ type: "text", text: "❌ 変更内容が指定されていません。\nreplacements, newHTML, attributeUpdates のいずれかを指定してください。" }],
1577
- isError: true,
1578
- };
1579
- }
1580
-
1581
- // ========================================
1582
- // Headless モード
1583
- // ========================================
1584
- if (mode === 'headless') {
1585
- const result = await client.headlessUpdate(modePostId, {
1586
- index, indices, startIndex, endIndex,
1587
- section, blockType, typeIndex, contains,
1588
- headingLevel, headingContains,
1589
- replacements, newHTML, attributeUpdates,
1590
- insertOnly: insertOnly || false,
1591
- insertPosition,
1592
- dryRun: dryRun || false,
1593
- });
1594
-
1595
- if (result.dryRun && result.results) {
1596
- const previewText = result.results
1597
- .map(r => {
1598
- const label = r.index !== undefined ? `[${r.index}]` :
1599
- r.indices ? `[${r.indices.join(', ')}]` : '[?]';
1600
- return ` ${label}: ${r.preview ? 'プレビューあり' : 'N/A'}`;
1601
- })
1602
- .join('\n');
1603
- return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText}` }] };
1604
- }
1605
-
1606
- if (result.results) {
1607
- const successCount = result.results.filter(r => r.success).length;
1608
- const failCount = result.results.filter(r => !r.success).length;
1609
- return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}` }] };
1610
- }
1611
-
1612
- return { content: [{ type: "text", text: `✅ 更新完了` }] };
1613
- }
1614
-
1615
- // ========================================
1616
- // Editor モード(現行ロジック)
1617
- // ========================================
1618
-
1619
- // target: "selected" の場合 → エディタ状態から選択情報を取得してMCP側で処理
1620
- if (target === "selected") {
1621
- const state = await client.getEditorState();
1622
- const sel = state.selectedBlock;
1623
-
1624
- if (!sel || (!sel.blockId && !sel.blockIds)) {
1625
- return {
1626
- content: [{ type: "text", text: "❌ ブロックが選択されていません。" }],
1627
- isError: true,
1628
- };
1629
- }
1630
-
1631
- const blockIndices = sel.isMultiSelect ? sel.blockIndices : [sel.blockIndex];
1632
-
1633
- // replacementsの場合、MCP側でカーソル選択ピンポイント差分を適用
1634
- if (replacements && replacements.length > 0) {
1635
- if (!sel.blockHTML) {
1636
- return {
1637
- content: [{ type: "text", text: "❌ 選択中ブロックのHTML情報がありません。" }],
1638
- isError: true,
1639
- };
1640
- }
1641
- let finalHTML = sel.blockHTML;
1642
- const hasTextSelection = sel.textSelection && sel.textSelection.text;
1643
- for (const replacement of replacements) {
1644
- if (!replacement.old || !replacement.new) continue;
1645
- if (hasTextSelection && sel.textSelection) {
1646
- const { text: selText, context } = sel.textSelection;
1647
- const searchPattern = context.before + selText + context.after;
1648
- if (finalHTML.includes(searchPattern)) {
1649
- const replacedPattern = context.before + replacement.new + context.after;
1650
- finalHTML = finalHTML.replace(searchPattern, replacedPattern);
1651
- } else {
1652
- finalHTML = finalHTML.replace(new RegExp(escapeRegExp(replacement.old), 'g'), replacement.new);
1653
- }
1654
- } else {
1655
- finalHTML = finalHTML.replace(new RegExp(escapeRegExp(replacement.old), 'g'), replacement.new);
1656
- }
1657
- }
1658
- const result = await client.sendEditorCommand("update_blocks", {
1659
- mode: "html_replace_by_index",
1660
- blockIndices,
1661
- newHTML: finalHTML,
1662
- insertOnly: insertOnly || false,
1663
- insertPosition,
1664
- }, 10000);
1665
- if (!result)
1666
- return timeoutResponse(name, client, args?.site);
1667
- if (!result.success)
1668
- return errorResponse(name, result.error, args?.site);
1669
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}` }] };
1670
- }
1671
-
1672
- // newHTMLの場合
1673
- if (newHTML) {
1674
- const result = await client.sendEditorCommand("update_blocks", {
1675
- mode: "html_replace_by_index",
1676
- blockIndices,
1677
- newHTML,
1678
- insertOnly: insertOnly || false,
1679
- insertPosition,
1680
- }, 10000);
1681
- if (!result)
1682
- return timeoutResponse(name, client, args?.site);
1683
- if (!result.success)
1684
- return errorResponse(name, result.error, args?.site);
1685
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了` }] };
1686
- }
1687
-
1688
- // attributeUpdatesの場合
1689
- const result = await client.sendEditorCommand("update_blocks", {
1690
- mode: "attribute_update_by_index",
1691
- blockIndices,
1692
- attributeUpdates,
1693
- dryRun: dryRun || false,
1694
- }, 10000);
1695
- if (!result)
1696
- return timeoutResponse(name, client, args?.site);
1697
- if (!result.success)
1698
- return errorResponse(name, result.error, args?.site);
1699
- return { content: [{ type: "text", text: `✅ 属性更新完了` }] };
1700
- }
1701
-
1702
- // その他のターゲット → 全てWP側で解決&実行
1703
- const maxWait = (section || blockType || contains) ? 15000 : 10000;
1704
- const result = await client.sendEditorCommand("update_blocks", {
1705
- index, indices, startIndex, endIndex,
1706
- section, blockType, typeIndex, contains,
1707
- headingLevel, headingContains,
1708
- replacements, newHTML, attributeUpdates,
1709
- insertOnly: insertOnly || false,
1710
- insertPosition,
1711
- dryRun: dryRun || false,
1712
- }, maxWait);
1713
- if (!result)
1714
- return timeoutResponse(name, client, args?.site);
1715
- if (!result.success)
1716
- return errorResponse(name, result.error, args?.site);
1717
-
1718
- // 結果フォーマット
1719
- if (dryRun && result.results) {
1720
- const previewText = result.results
1721
- .filter(r => r.preview)
1722
- .map(r => {
1723
- if (Array.isArray(r.preview)) {
1724
- return ` [${r.index}]\n${r.preview.map(p => ` "${p.old}" → "${p.new}" (${p.count}箇所)`).join('\n')}`;
1725
- } else if (r.preview?.willUpdate) {
1726
- return ` [${r.index}]: 属性更新予定 ${JSON.stringify(r.preview.willUpdate)}`;
1727
- } else if (r.preview?.skipped) {
1728
- return ` [${r.index}]: スキップ (${r.preview.skipped})`;
1729
- }
1730
- return ` [${r.index}]: プレビュー不可`;
1731
- })
1732
- .join('\n');
1733
- return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText || "(対象なし)"}` }] };
1734
- }
1735
-
1736
- if (result.results) {
1737
- const successCount = result.results.filter(r => r.success && !r.skipped).length;
1738
- const skipCount = result.results.filter(r => r.skipped).length;
1739
- const failCount = result.results.filter(r => !r.success).length;
1740
- const detailText = result.results.filter(r => r.success && r.replaceCount > 0).map(r => ` [${r.index}]: ${r.replaceCount}箇所`).join('\n');
1741
- return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}` }] };
1742
- }
1743
-
1744
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了` }] };
2284
+ return await handleUpdateBlocksTool(args, name);
1745
2285
  }
1746
2286
 
1747
2287
  case "table_operations": {
1748
- const { mode, postId, message, client } = await resolveMode(args);
2288
+ const { mode, postId: _postId, message, client } = await resolveMode(args);
1749
2289
  if (mode === 'error') {
1750
2290
  return errorResponse(name, message, args?.site);
1751
2291
  }
2292
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
2293
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
2294
+ const postId = _resolved.postId ?? _postId;
1752
2295
 
1753
2296
  const { index, action } = (args || {});
1754
2297
  if (index === undefined) {
@@ -1762,7 +2305,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1762
2305
  index, action,
1763
2306
  row: args.row, col: args.col,
1764
2307
  content: args.content, position: args.position,
1765
- cells: args.cells, style: args.style,
2308
+ cells: args.cells,
1766
2309
  };
1767
2310
 
1768
2311
  // get_structure の結果をフォーマットするヘルパー
@@ -1775,7 +2318,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1775
2318
  for (const row of section.rows) {
1776
2319
  const cellTexts = row.cells.map((c, ci) => {
1777
2320
  const content = c.content.replace(/<[^>]*>/g, '').trim();
1778
- return `[${row.globalRow},${ci}] ${content || '(空)'}`;
2321
+ const flags = [];
2322
+ if (/<img\b/i.test(c.content)) flags.push('img');
2323
+ if (/<a[\s>]/i.test(c.content)) flags.push('a');
2324
+ if (c.style) flags.push('style');
2325
+ const flagStr = flags.length > 0 ? ` [${flags.join(',')}]` : '';
2326
+ return `[${row.globalRow},${ci}] ${content || '(空)'}${flagStr}`;
1779
2327
  });
1780
2328
  text += ` 行${row.globalRow}: ${cellTexts.join(' | ')}\n`;
1781
2329
  }
@@ -1804,9 +2352,57 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1804
2352
  return { content: [{ type: "text", text: `✅ ${action} 完了: ${JSON.stringify(result.detail || {})}` }] };
1805
2353
  }
1806
2354
 
2355
+ case "open_in_browser": {
2356
+ const target = args?.target || 'editor';
2357
+ if (target !== 'editor' && target !== 'front') {
2358
+ return { content: [{ type: "text", text: `❌ target は "editor" または "front" を指定してください(指定値: "${target}")` }], isError: true };
2359
+ }
2360
+
2361
+ let { mode, postId: _postId, message, client, errorCode } = await resolveMode(args);
2362
+
2363
+ // Editor conflict は読み取り専用なので無視して続行
2364
+ if (mode === 'error' && errorCode === 'editor_conflict') {
2365
+ mode = 'headless';
2366
+ } else if (mode === 'error') {
2367
+ return errorResponse(name, message, args?.site);
2368
+ }
2369
+
2370
+ let postId;
2371
+ if (mode === 'editor') {
2372
+ postId = _postId;
2373
+ } else {
2374
+ const resolved = await resolvePostId(args?.postId, client, { skipConflictCheck: true });
2375
+ if (resolved.error) return { content: [{ type: "text", text: resolved.error }], isError: true };
2376
+ postId = resolved.postId ?? _postId;
2377
+ }
2378
+
2379
+ let url;
2380
+ if (target === 'front') {
2381
+ const meta = await client.headlessGetMeta(postId);
2382
+ url = meta.link;
2383
+ } else {
2384
+ url = `${client.wpUrl.replace(/\/$/, '')}/wp-admin/post.php?post=${postId}&action=edit`;
2385
+ }
2386
+
2387
+ if (!isValidHttpUrl(url)) {
2388
+ return { content: [{ type: "text", text: `❌ 無効なURLです: ${url}` }], isError: true };
2389
+ }
2390
+
2391
+ try {
2392
+ await openInBrowser(url);
2393
+ const label = target === 'front' ? 'フロントページ' : 'エディタ';
2394
+ return { content: [{ type: "text", text: `🌐 ${label}をブラウザで開きました\nURL: ${url}` }] };
2395
+ } catch (e) {
2396
+ return { content: [{ type: "text", text: `⚠️ ブラウザの起動に失敗しました。URLを手動で開いてください:\n${url}\n\nエラー: ${e.message}` }], isError: true };
2397
+ }
2398
+ }
2399
+
1807
2400
  case "list_connections": {
1808
2401
  const conns = registry.list();
1809
- const text = conns.map(c => ` ${c.name}: ${c.url} (${c.user})`).join('\n');
2402
+ const text = conns.map(c => {
2403
+ const mark = c.isDefault ? ' (default)' : '';
2404
+ return ` ${c.name}${mark}: ${c.url} (${c.user})`;
2405
+ }).join('\n');
1810
2406
  return { content: [{ type: "text", text: `🔗 接続一覧 (${conns.length}件)\n\n${text}` }] };
1811
2407
  }
1812
2408