friday-mcp-v2 3.0.6 → 3.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.
@@ -14,7 +14,8 @@ import fetch from "node-fetch";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { readFileSync, statSync, realpathSync, appendFileSync, mkdirSync, existsSync, writeFileSync, renameSync } from "node:fs";
16
16
  import { execFile } from "node:child_process";
17
- import { resolve as resolvePath, sep, dirname, join as joinPath } from "node:path";
17
+ import { resolve as resolvePath, sep, dirname, join as joinPath, extname } from "node:path";
18
+ import { marked } from "marked";
18
19
  import os from "node:os";
19
20
  import { fileURLToPath } from "node:url";
20
21
  // package.json からバージョンを取得
@@ -456,6 +457,159 @@ function readHTMLFromFile(filePath, maxSizeBytes = 2 * 1024 * 1024) {
456
457
  return { html };
457
458
  }
458
459
 
460
+ // ========================================
461
+ // Media Utilities
462
+ // ========================================
463
+
464
+ const ALLOWED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.avif']);
465
+ const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
466
+
467
+ const MIME_MAP = {
468
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
469
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif',
470
+ };
471
+
472
+ function validateImageFile(filePath) {
473
+ const resolved = resolvePath(filePath);
474
+ let stat;
475
+ try { stat = statSync(resolved); }
476
+ catch (e) {
477
+ if (e.code === 'ENOENT') throw new Error(`File not found: ${resolved}`);
478
+ throw new Error(`File access error: ${resolved} (${e.message})`);
479
+ }
480
+ if (stat.size > MAX_IMAGE_SIZE_BYTES) {
481
+ throw new Error(`File too large: ${(stat.size / 1048576).toFixed(1)}MB (max: 10MB)`);
482
+ }
483
+ const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
484
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
485
+ throw new Error(`Unsupported image format: ${ext} (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(', ')})`);
486
+ }
487
+ const contentType = MIME_MAP[ext] || 'application/octet-stream';
488
+ return { resolved, stat, ext, contentType };
489
+ }
490
+
491
+ // ========================================
492
+ // Search Variant Generation (ひらがな⇔カタカナ⇔ローマ字)
493
+ // ========================================
494
+
495
+ const ROMAJI_MAP = {
496
+ 'キャ': 'kya', 'キュ': 'kyu', 'キョ': 'kyo',
497
+ 'シャ': 'sha', 'シュ': 'shu', 'ショ': 'sho',
498
+ 'チャ': 'cha', 'チュ': 'chu', 'チョ': 'cho',
499
+ 'ニャ': 'nya', 'ニュ': 'nyu', 'ニョ': 'nyo',
500
+ 'ヒャ': 'hya', 'ヒュ': 'hyu', 'ヒョ': 'hyo',
501
+ 'ミャ': 'mya', 'ミュ': 'myu', 'ミョ': 'myo',
502
+ 'リャ': 'rya', 'リュ': 'ryu', 'リョ': 'ryo',
503
+ 'ギャ': 'gya', 'ギュ': 'gyu', 'ギョ': 'gyo',
504
+ 'ジャ': 'ja', 'ジュ': 'ju', 'ジョ': 'jo',
505
+ 'ビャ': 'bya', 'ビュ': 'byu', 'ビョ': 'byo',
506
+ 'ピャ': 'pya', 'ピュ': 'pyu', 'ピョ': 'pyo',
507
+ 'ティ': 'ti', 'ディ': 'di', 'ファ': 'fa', 'フィ': 'fi', 'フェ': 'fe', 'フォ': 'fo',
508
+ 'ア': 'a', 'イ': 'i', 'ウ': 'u', 'エ': 'e', 'オ': 'o',
509
+ 'カ': 'ka', 'キ': 'ki', 'ク': 'ku', 'ケ': 'ke', 'コ': 'ko',
510
+ 'サ': 'sa', 'シ': 'shi', 'ス': 'su', 'セ': 'se', 'ソ': 'so',
511
+ 'タ': 'ta', 'チ': 'chi', 'ツ': 'tsu', 'テ': 'te', 'ト': 'to',
512
+ 'ナ': 'na', 'ニ': 'ni', 'ヌ': 'nu', 'ネ': 'ne', 'ノ': 'no',
513
+ 'ハ': 'ha', 'ヒ': 'hi', 'フ': 'fu', 'ヘ': 'he', 'ホ': 'ho',
514
+ 'マ': 'ma', 'ミ': 'mi', 'ム': 'mu', 'メ': 'me', 'モ': 'mo',
515
+ 'ヤ': 'ya', 'ユ': 'yu', 'ヨ': 'yo',
516
+ 'ラ': 'ra', 'リ': 'ri', 'ル': 'ru', 'レ': 're', 'ロ': 'ro',
517
+ 'ワ': 'wa', 'ヲ': 'wo', 'ン': 'n',
518
+ 'ガ': 'ga', 'ギ': 'gi', 'グ': 'gu', 'ゲ': 'ge', 'ゴ': 'go',
519
+ 'ザ': 'za', 'ジ': 'ji', 'ズ': 'zu', 'ゼ': 'ze', 'ゾ': 'zo',
520
+ 'ダ': 'da', 'ヂ': 'di', 'ヅ': 'du', 'デ': 'de', 'ド': 'do',
521
+ 'バ': 'ba', 'ビ': 'bi', 'ブ': 'bu', 'ベ': 'be', 'ボ': 'bo',
522
+ 'パ': 'pa', 'ピ': 'pi', 'プ': 'pu', 'ペ': 'pe', 'ポ': 'po',
523
+ 'ァ': 'a', 'ィ': 'i', 'ゥ': 'u', 'ェ': 'e', 'ォ': 'o',
524
+ 'ヴ': 'v', 'ー': '', 'ッ': '',
525
+ };
526
+
527
+ function hiraToKata(text) {
528
+ return text.replace(/[ぁ-ゖ]/g, c => String.fromCharCode(c.charCodeAt(0) + 96));
529
+ }
530
+
531
+ function kataToHira(text) {
532
+ return text.replace(/[ァ-ヶ]/g, c => String.fromCharCode(c.charCodeAt(0) - 96));
533
+ }
534
+
535
+ function kataToRomaji(text) {
536
+ const result = [];
537
+ let i = 0;
538
+ while (i < text.length) {
539
+ if (i + 1 < text.length && ROMAJI_MAP[text.slice(i, i + 2)]) {
540
+ result.push(ROMAJI_MAP[text.slice(i, i + 2)]);
541
+ i += 2;
542
+ } else if (text[i] === 'ッ' && i + 1 < text.length) {
543
+ // 促音: 次の子音を重ねる
544
+ const next2 = (i + 2 < text.length) ? ROMAJI_MAP[text.slice(i + 1, i + 3)] : null;
545
+ const next1 = ROMAJI_MAP[text[i + 1]];
546
+ const nextRoma = next2 || next1;
547
+ if (nextRoma && nextRoma.length > 0) result.push(nextRoma[0]);
548
+ i += 1;
549
+ } else if (ROMAJI_MAP[text[i]] !== undefined) {
550
+ result.push(ROMAJI_MAP[text[i]]);
551
+ i += 1;
552
+ } else {
553
+ result.push(text[i]);
554
+ i += 1;
555
+ }
556
+ }
557
+ return result.join('');
558
+ }
559
+
560
+ function toRomaji(text) {
561
+ const kata = hiraToKata(text);
562
+ const romaji = kataToRomaji(kata);
563
+ if (/[一-鿿]/.test(romaji)) return '';
564
+ return romaji;
565
+ }
566
+
567
+ function generateSearchVariants(query, strip = [], prefixes = []) {
568
+ const variants = new Set();
569
+ let name = query;
570
+ variants.add(name);
571
+
572
+ if (strip && strip.length > 0) {
573
+ for (const suffix of strip) {
574
+ if (name.endsWith(suffix)) {
575
+ name = name.slice(0, -suffix.length);
576
+ variants.add(name);
577
+ break;
578
+ }
579
+ }
580
+ }
581
+
582
+ const bracketMatch = name.match(/[((]([^))]+)[))]/);
583
+ if (bracketMatch) {
584
+ const reading = bracketMatch[1];
585
+ const base = name.replace(/[((][^))]+[))]/, '').trim();
586
+ variants.add(base);
587
+ variants.add(reading);
588
+ variants.add(hiraToKata(reading));
589
+ variants.add(kataToHira(reading));
590
+ const romaji = toRomaji(reading);
591
+ if (romaji) variants.add(romaji);
592
+ name = base;
593
+ } else {
594
+ variants.add(hiraToKata(name));
595
+ variants.add(kataToHira(name));
596
+ const romaji = toRomaji(name);
597
+ if (romaji) variants.add(romaji);
598
+ }
599
+
600
+ if (prefixes && prefixes.length > 0) {
601
+ for (const prefix of prefixes) {
602
+ variants.add(`${prefix}${name}`);
603
+ if (bracketMatch) {
604
+ variants.add(`${prefix}${bracketMatch[1]}`);
605
+ }
606
+ }
607
+ }
608
+
609
+ variants.delete('');
610
+ return [...variants];
611
+ }
612
+
459
613
  /**
460
614
  * 単一クライアントの Editor/Headless 判定
461
615
  * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
@@ -679,10 +833,15 @@ async function resolvePostId(rawPostId, client, { skipConflictCheck = false } =
679
833
  }
680
834
  if (!slug) return { error: '❌ URL からスラッグを抽出できませんでした。' };
681
835
 
682
- // slug → ID 解決
683
- const { data: posts } = await client.listPosts({ slug, status: 'any' });
836
+ // slug → ID 解決(投稿 + 固定ページを統合検索)
837
+ const searchResult = await client.searchPosts({ slug, status: 'any', post_type: 'any' });
838
+ const posts = searchResult.items || [];
684
839
  if (!posts || posts.length === 0) {
685
- return { error: `❌ スラッグ "${slug}" に一致する投稿が見つかりません。` };
840
+ return { error: `❌ スラッグ "${slug}" に一致する投稿・固定ページが見つかりません。` };
841
+ }
842
+ if (posts.length > 1) {
843
+ const candidates = posts.map(p => ` [${p.id}] ${p.post_type === 'page' ? '[固定ページ]' : '[投稿]'} ${p.title || '(無題)'} — ${p.link || '-'}`).join('\n');
844
+ return { error: `❌ スラッグ "${slug}" に複数の候補が見つかりました。postId を数値で指定してください:\n${candidates}` };
686
845
  }
687
846
  const resolvedId = posts[0].id;
688
847
 
@@ -2056,7 +2215,7 @@ const tools = [
2056
2215
  },
2057
2216
  {
2058
2217
  name: "select_block",
2059
- description: "Select block(s). Index is 0-based flattened.",
2218
+ description: "Select block(s) by ref, section, blockType, or contains.",
2060
2219
  inputSchema: {
2061
2220
  type: "object",
2062
2221
  properties: {
@@ -2161,7 +2320,7 @@ const tools = [
2161
2320
  },
2162
2321
  {
2163
2322
  name: "update_post_meta",
2164
- description: "Update post metadata.",
2323
+ description: "Update post or page metadata.",
2165
2324
  inputSchema: {
2166
2325
  type: "object",
2167
2326
  properties: {
@@ -2170,16 +2329,39 @@ const tools = [
2170
2329
  title: { type: "string", description: "Title" },
2171
2330
  status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status" },
2172
2331
  slug: { type: "string", description: "Slug (URL)" },
2173
- categories: { type: "array", items: { type: "number" }, description: "Category IDs" },
2174
- tags: { type: "array", items: { type: "number" }, description: "Tag IDs" },
2332
+ categories: { type: "array", items: { type: "number" }, description: "Category IDs (posts only)" },
2333
+ tags: { type: "array", items: { type: "number" }, description: "Tag IDs (posts only)" },
2334
+ parent: { type: "number", description: "Parent page ID (pages only, 0 to remove)" },
2175
2335
  excerpt: { type: "string", description: "Excerpt" },
2176
2336
  featured_media: { type: "number", description: "Featured image ID (0 to remove)" },
2177
2337
  },
2178
2338
  },
2179
2339
  },
2340
+ {
2341
+ name: "create_post",
2342
+ description: "Create a new WordPress post or page (draft by default). Returns post ID and editor URL.",
2343
+ inputSchema: {
2344
+ type: "object",
2345
+ properties: {
2346
+ title: { type: "string", description: "Post title" },
2347
+ post_type: { type: "string", enum: ["post", "page"], description: "Content type (default: post)" },
2348
+ slug: { type: "string", description: "URL slug" },
2349
+ status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status (default: draft)" },
2350
+ categories: { type: "array", items: { type: "number" }, description: "Category IDs (posts only)" },
2351
+ tags: { type: "array", items: { type: "number" }, description: "Tag IDs (posts only)" },
2352
+ parent: { type: "number", description: "Parent page ID (pages only)" },
2353
+ featured_media: { type: "number", description: "Featured image media ID" },
2354
+ excerpt: { type: "string", description: "Excerpt" },
2355
+ content: { type: "string", description: "HTML content (exclusive with filePath)" },
2356
+ filePath: { type: "string", description: "Local file path (.md or .html, exclusive with content)" },
2357
+ site: siteParam,
2358
+ },
2359
+ required: ["title"],
2360
+ },
2361
+ },
2180
2362
  {
2181
2363
  name: "list_posts",
2182
- description: "Search posts. Use slug for exact match, search for keyword. Mutually exclusive.",
2364
+ description: "Search posts and pages. Use slug for exact match, search for keyword. Mutually exclusive.",
2183
2365
  inputSchema: {
2184
2366
  type: "object",
2185
2367
  properties: {
@@ -2187,6 +2369,7 @@ const tools = [
2187
2369
  slug: { type: "string", description: "Slug for exact match (exclusive with search)" },
2188
2370
  search: { type: "string", description: "Keyword (exclusive with slug)" },
2189
2371
  status: { type: "string", enum: ["publish", "draft", "pending", "private", "any"], description: "Status filter (default: publish)" },
2372
+ post_type: { type: "string", enum: ["post", "page", "any"], description: "Filter by type: post, page, or any (default: any)" },
2190
2373
  categories: { type: "array", items: { type: "number" }, description: "Category IDs" },
2191
2374
  tags: { type: "array", items: { type: "number" }, description: "Tag IDs" },
2192
2375
  per_page: { type: "integer", description: "Per page (1-100)", minimum: 1, maximum: 100 },
@@ -2196,6 +2379,83 @@ const tools = [
2196
2379
  },
2197
2380
  },
2198
2381
  },
2382
+ {
2383
+ name: "list_blog_parts",
2384
+ description: "List or search blog parts (reusable blocks). SWELL theme only.",
2385
+ inputSchema: {
2386
+ type: "object",
2387
+ properties: {
2388
+ site: siteParam,
2389
+ search: { type: "string", description: "Keyword search" },
2390
+ status: { type: "string", enum: ["publish", "draft", "private", "any"], description: "Status filter (default: publish)" },
2391
+ per_page: { type: "integer", description: "Per page (1-100)", minimum: 1, maximum: 100 },
2392
+ page: { type: "integer", description: "Page number", minimum: 1 },
2393
+ },
2394
+ },
2395
+ },
2396
+ {
2397
+ name: "get_blog_parts",
2398
+ description: "Get blog parts structure and blocks by partsId. Opens a blog parts post for viewing/editing.",
2399
+ inputSchema: {
2400
+ type: "object",
2401
+ properties: {
2402
+ partsId: { type: "integer", description: "Blog parts ID (required)" },
2403
+ site: siteParam,
2404
+ full: { type: "boolean", description: "Full block data" },
2405
+ section: { type: "string", description: "Zoom into section" },
2406
+ blockType: { type: "string", description: "Filter by block type" },
2407
+ contains: { type: "string", description: "Text search" },
2408
+ },
2409
+ required: ["partsId"],
2410
+ },
2411
+ },
2412
+ {
2413
+ name: "create_blog_parts",
2414
+ description: "Create a new blog parts (reusable block). SWELL theme only. Returns ID and editor URL.",
2415
+ inputSchema: {
2416
+ type: "object",
2417
+ properties: {
2418
+ title: { type: "string", description: "Blog parts title" },
2419
+ status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status (default: draft)" },
2420
+ slug: { type: "string", description: "URL slug" },
2421
+ content: { type: "string", description: "HTML content (exclusive with filePath)" },
2422
+ filePath: { type: "string", description: "Local file path (.md or .html, exclusive with content)" },
2423
+ parts_use: { type: "array", items: { type: "number" }, description: "parts_use taxonomy term IDs (SWELL usage category)" },
2424
+ site: siteParam,
2425
+ },
2426
+ required: ["title"],
2427
+ },
2428
+ },
2429
+ {
2430
+ name: "update_blog_parts",
2431
+ description: "Update blog parts blocks. Same as update_blocks but for blog parts posts. Headless only.",
2432
+ inputSchema: {
2433
+ type: "object",
2434
+ properties: {
2435
+ partsId: { type: "integer", description: "Blog parts ID (required)" },
2436
+ site: siteParam,
2437
+ snapshotId: { type: "string", description: "Snapshot ID from get_blog_parts." },
2438
+ ref: { type: "string", description: "Block ref from snapshot." },
2439
+ refs: { type: "array", items: { type: "string" }, description: "Multiple block refs." },
2440
+ expectedRevision: { type: "string", description: "Revision check." },
2441
+ newHTML: { type: "string", description: "New block HTML." },
2442
+ replacements: {
2443
+ type: "array",
2444
+ items: {
2445
+ type: "object",
2446
+ properties: {
2447
+ old: { type: "string" },
2448
+ new: { type: "string" },
2449
+ regex: { type: "boolean" },
2450
+ },
2451
+ required: ["old", "new"],
2452
+ },
2453
+ description: "Text replacements.",
2454
+ },
2455
+ },
2456
+ required: ["partsId"],
2457
+ },
2458
+ },
2199
2459
  {
2200
2460
  name: "list_taxonomies",
2201
2461
  description: "List categories or tags.",
@@ -2418,6 +2678,36 @@ const tools = [
2418
2678
  },
2419
2679
  },
2420
2680
  },
2681
+ {
2682
+ name: "upload_media",
2683
+ description: "Upload image to WordPress media library. Returns media ID, URL, dimensions.",
2684
+ inputSchema: {
2685
+ type: "object",
2686
+ properties: {
2687
+ site: siteParam,
2688
+ filePath: { type: "string", description: "Absolute path to image file on local machine" },
2689
+ name: { type: "string", description: "New filename (without extension, ASCII/hyphens recommended)" },
2690
+ alt: { type: "string", description: "Alt text and title (Japanese OK)" },
2691
+ title: { type: "string", description: "Title (defaults to alt if omitted)" },
2692
+ },
2693
+ required: ["filePath", "name", "alt"],
2694
+ },
2695
+ },
2696
+ {
2697
+ name: "search_media",
2698
+ description: "Search WordPress media library. Auto-generates hiragana/katakana/romaji variants for broader matching.",
2699
+ inputSchema: {
2700
+ type: "object",
2701
+ properties: {
2702
+ site: siteParam,
2703
+ query: { type: "string", description: "Search keyword" },
2704
+ strip: { type: "array", items: { type: "string" }, description: "Suffixes to strip (e.g. ['先生', 'さん'])" },
2705
+ prefix: { type: "array", items: { type: "string" }, description: "Prefixes to add (e.g. ['エキサイト-'])" },
2706
+ per_page: { type: "integer", description: "Results per variant (1-20, default: 10)", minimum: 1, maximum: 20 },
2707
+ },
2708
+ required: ["query"],
2709
+ },
2710
+ },
2421
2711
  {
2422
2712
  name: "list_connections",
2423
2713
  description: "List connections.",
@@ -2449,6 +2739,38 @@ const tools = [
2449
2739
  required: ["content"],
2450
2740
  },
2451
2741
  },
2742
+ {
2743
+ name: "search_asp_link",
2744
+ description: "Search registered ASP affiliate links. Search by title, slug, or keyword across title and asp fields.",
2745
+ inputSchema: {
2746
+ type: "object",
2747
+ properties: {
2748
+ site: siteParam,
2749
+ search: { type: "string", description: "Keyword search (title, asp_id, asp_name)" },
2750
+ slug: { type: "string", description: "Exact post slug match (exclusive with search)" },
2751
+ per_page: { type: "integer", minimum: 1, maximum: 100, description: "Results per page (default: 20)" },
2752
+ page: { type: "integer", minimum: 1, description: "Page number (default: 1)" },
2753
+ },
2754
+ },
2755
+ },
2756
+ {
2757
+ name: "register_asp_link",
2758
+ description: "Register a new ASP affiliate link. Creates a jump post with ASP metadata.",
2759
+ inputSchema: {
2760
+ type: "object",
2761
+ properties: {
2762
+ site: siteParam,
2763
+ title: { type: "string", description: "Display name (e.g. 'モコム')" },
2764
+ slug: { type: "string", description: "WordPress post slug for URL (e.g. 'mocom')" },
2765
+ asp_id: { type: "string", description: "ASP案件ID — data-id attribute used by JSLinkHelper (defaults to slug if omitted)" },
2766
+ asp_name: { type: "string", description: "案件名 (defaults to title if omitted)" },
2767
+ asp_url: { type: "string", description: "ASP affiliate URL (required)" },
2768
+ asp_url_sp: { type: "string", description: "ASP URL (B) for A/B testing (optional)" },
2769
+ direct_url: { type: "string", description: "Direct URL bypassing ASP (optional)" },
2770
+ },
2771
+ required: ["title", "slug", "asp_url"],
2772
+ },
2773
+ },
2452
2774
  ];
2453
2775
 
2454
2776
  // ツールリストのハンドラ
@@ -2599,7 +2921,7 @@ async function handleUpdateBlocksTool(args, toolName) {
2599
2921
  // Headless モードで target: "selected" はエラー
2600
2922
  if (mode === 'headless' && target === 'selected') {
2601
2923
  return {
2602
- content: [{ type: "text", text: "❌ target:'selected' はエディタ接続時のみ使用可能です。index を指定してください。" }],
2924
+ content: [{ type: "text", text: "❌ target:'selected' はエディタ接続時のみ使用可能です。ref+snapshotId, section, blockType, contains 等を指定してください。" }],
2603
2925
  isError: true,
2604
2926
  };
2605
2927
  }
@@ -2610,7 +2932,7 @@ async function handleUpdateBlocksTool(args, toolName) {
2610
2932
  (headingLevel && headingContains);
2611
2933
  if (!hasTarget && !(appendToEnd && insertOnly)) {
2612
2934
  return {
2613
- content: [{ type: "text", text: "❌ ターゲットが指定されていません。\ntarget, index, indices, section, blockType, contains 等のいずれかを指定してください。" }],
2935
+ content: [{ type: "text", text: "❌ ターゲットが指定されていません。\nref+snapshotId, section, blockType, contains 等のいずれかを指定してください。" }],
2614
2936
  isError: true,
2615
2937
  };
2616
2938
  }
@@ -2846,7 +3168,7 @@ async function handleUpdateBlocksTool(args, toolName) {
2846
3168
  }
2847
3169
 
2848
3170
  // ツール実行のハンドラ
2849
- const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations']);
3171
+ const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations', 'update_blog_parts']);
2850
3172
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2851
3173
  const { name, arguments: args } = request.params;
2852
3174
  const _toolLogStart = Date.now();
@@ -2967,10 +3289,49 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2967
3289
  const endIdx = (limit && limit > 0) ? startIdx + limit : blocks.length;
2968
3290
  blocks = blocks.slice(startIdx, endIdx);
2969
3291
 
3292
+ // エディタモード時、ブロパの展開情報をヘッドレス API で補完
3293
+ if (mode === 'editor' && client) {
3294
+ for (const b of blocks) {
3295
+ if (b.type === 'loos/blog-parts' && !b.blogParts) {
3296
+ const pid = parseInt(b.attributes?.partsID, 10);
3297
+ if (pid > 0) {
3298
+ try {
3299
+ const bpBlocks = await client.headlessGetBlocks(pid);
3300
+ const meta = await client.headlessGetMeta(pid);
3301
+ b.blogParts = {
3302
+ partsId: pid,
3303
+ partsTitle: meta?.title || '',
3304
+ blocks: (bpBlocks?.blocks || []).map(bl => ({ type: bl.type, depth: bl.depth || 0 })),
3305
+ };
3306
+ } catch (_) {
3307
+ b.blogPartsError = 'not_found';
3308
+ }
3309
+ }
3310
+ }
3311
+ }
3312
+ }
3313
+
2970
3314
  const blockList = blocks.map(b => {
2971
3315
  const attrPreview = JSON.stringify(b.attributes || {}).slice(0, 200);
2972
3316
  const t = formatTreeLine(b);
2973
- 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 ? '...' : ''}`;
3317
+ let line = `${t.indent}[${b.index}${_refTag(b.index)}] ${t.marker}${b.type}${t.childInfo} - ${b.section || "(記事冒頭)"} ${t.depthInfo}\n${t.indent} ${attrPreview}${attrPreview.length >= 200 ? '...' : ''}`;
3318
+
3319
+ // ブロパ展開表示(読み取り専用、ref なし)
3320
+ if (b.blogParts) {
3321
+ line += `\n${t.indent} 📦 "${b.blogParts.partsTitle}" [ID:${b.blogParts.partsId}]`;
3322
+ const innerBlocks = b.blogParts.blocks || [];
3323
+ for (const inner of innerBlocks.slice(0, 10)) {
3324
+ const innerIndent = ' '.repeat((inner.depth || 0) + 1);
3325
+ line += `\n${t.indent} ${innerIndent}└ ${inner.type}`;
3326
+ }
3327
+ if (innerBlocks.length > 10) {
3328
+ line += `\n${t.indent} ... 他 ${innerBlocks.length - 10} ブロック`;
3329
+ }
3330
+ } else if (b.blogPartsError) {
3331
+ line += `\n${t.indent} ⚠️ ブログパーツ展開失敗: ${b.blogPartsError}`;
3332
+ }
3333
+
3334
+ return line;
2974
3335
  }).join("\n\n");
2975
3336
 
2976
3337
  let paginationInfo = '';
@@ -3313,6 +3674,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3313
3674
  output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index}${_refTag(heading.index)})\n`;
3314
3675
  }
3315
3676
 
3677
+ // ブログパーツ一覧
3678
+ if (state.blockSummary && state.blockSummary['loos/blog-parts']) {
3679
+ const bpEntries = state.blockSummary['loos/blog-parts'].blocks || [];
3680
+ const bpItems = [];
3681
+ for (const bp of bpEntries) {
3682
+ const pid = bp.partsId || (bp.attributes?.partsID ? parseInt(bp.attributes.partsID, 10) : 0);
3683
+ if (!pid) continue;
3684
+ let title = bp.partsTitle || '';
3685
+ // エディタモード等でタイトルが空の場合、ヘッドレス API で補完
3686
+ if (!title && client) {
3687
+ try {
3688
+ const meta = await client.headlessGetMeta(pid);
3689
+ title = meta?.title || '';
3690
+ } catch (_) {}
3691
+ }
3692
+ bpItems.push({ index: bp.index, partsId: pid, title });
3693
+ }
3694
+ if (bpItems.length > 0) {
3695
+ output += `\nブログパーツ:\n`;
3696
+ for (const bp of bpItems) {
3697
+ output += ` [${bp.index}] 📦 "${bp.title || '(不明)'}" [ID:${bp.partsId}]\n`;
3698
+ }
3699
+ output += ` ※ 中身の確認は get_blog_parts(partsId) を使用\n`;
3700
+ }
3701
+ }
3702
+
3316
3703
  return {
3317
3704
  content: [{ type: "text", text: output + _snapshotLine + _modeTag_gas }],
3318
3705
  };
@@ -3333,7 +3720,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3333
3720
  // BUG-3: select_block では selected は使用不可
3334
3721
  if (tp.target === "selected") {
3335
3722
  return {
3336
- content: [{ type: "text", text: "❌ select_block では selected は使用できません。index, blockType, range 等を指定してください。" }],
3723
+ content: [{ type: "text", text: "❌ select_block では selected は使用できません。ref+snapshotId, blockType, contains 等を指定してください。" }],
3337
3724
  isError: true,
3338
3725
  };
3339
3726
  }
@@ -3346,7 +3733,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3346
3733
  (headingLevel && headingContains);
3347
3734
  if (!hasTarget) {
3348
3735
  return {
3349
- content: [{ type: "text", text: "❌ ターゲットが指定されていません。index, blockType, range, heading, contains 等のいずれかを指定してください。" }],
3736
+ content: [{ type: "text", text: "❌ ターゲットが指定されていません。ref+snapshotId, blockType, heading, contains 等のいずれかを指定してください。" }],
3350
3737
  isError: true,
3351
3738
  };
3352
3739
  }
@@ -3751,21 +4138,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3751
4138
  const statusInfo = mode === 'editor'
3752
4139
  ? `モード: Editor (接続中)`
3753
4140
  : `モード: Headless (postId: ${postId})`;
3754
- const text = [
3755
- `📄 投稿メタ情報`,
4141
+ const isPage = m.post_type === 'page';
4142
+ const typeLabel = isPage ? '📄 固定ページメタ情報' : '📄 投稿メタ情報';
4143
+ const lines = [
4144
+ typeLabel,
3756
4145
  ``,
3757
4146
  statusInfo,
3758
4147
  `ID: ${m.id}`,
4148
+ `タイプ: ${m.post_type || 'post'}`,
3759
4149
  `タイトル: ${m.title}`,
3760
4150
  `ステータス: ${m.status}`,
3761
4151
  `スラッグ: ${m.slug}`,
3762
4152
  `URL: ${m.link}`,
3763
- `カテゴリ: ${JSON.stringify(m.categories)}`,
3764
- `タグ: ${JSON.stringify(m.tags)}`,
3765
- `アイキャッチ: ${m.featuredImage || 'なし'}`,
3766
- `抜粋: ${m.excerpt || 'なし'}`,
3767
- `更新日: ${m.modified}`,
3768
- ].join('\n');
4153
+ ];
4154
+ if (isPage) {
4155
+ lines.push(`親ページ: ${m.parent || 'なし'}`);
4156
+ } else {
4157
+ lines.push(`カテゴリ: ${JSON.stringify(m.categories)}`);
4158
+ lines.push(`タグ: ${JSON.stringify(m.tags)}`);
4159
+ }
4160
+ lines.push(`アイキャッチ: ${m.featuredImage || 'なし'}`);
4161
+ lines.push(`抜粋: ${m.excerpt || 'なし'}`);
4162
+ lines.push(`更新日: ${m.modified}`);
4163
+ const text = lines.join('\n');
3769
4164
  return { content: [{ type: "text", text }] };
3770
4165
  }
3771
4166
 
@@ -3791,6 +4186,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3791
4186
  if (args.slug !== undefined) updateData.slug = args.slug;
3792
4187
  if (args.categories !== undefined) updateData.categories = args.categories;
3793
4188
  if (args.tags !== undefined) updateData.tags = args.tags;
4189
+ if (args.parent !== undefined) updateData.parent = args.parent;
3794
4190
  if (args.excerpt !== undefined) updateData.excerpt = args.excerpt;
3795
4191
  if (args.featured_media !== undefined) updateData.featured_media = args.featured_media;
3796
4192
 
@@ -3812,6 +4208,85 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3812
4208
  return { content: [{ type: "text", text: updateText }] };
3813
4209
  }
3814
4210
 
4211
+ case "create_post": {
4212
+ let client;
4213
+ try {
4214
+ client = registry.get(args?.site);
4215
+ } catch (e) {
4216
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4217
+ }
4218
+
4219
+ const title = args?.title?.trim();
4220
+ if (!title) {
4221
+ return { content: [{ type: "text", text: "❌ title は必須です。" }], isError: true };
4222
+ }
4223
+
4224
+ let content = args?.content;
4225
+ const filePath = args?.filePath;
4226
+
4227
+ if (content !== undefined && filePath !== undefined) {
4228
+ return { content: [{ type: "text", text: "❌ content と filePath は同時に指定できません。どちらか一方を指定してください。" }], isError: true };
4229
+ }
4230
+
4231
+ if (filePath) {
4232
+ try {
4233
+ const ext = extname(filePath).toLowerCase();
4234
+ const raw = readHTMLFromFile(filePath).html;
4235
+ if (ext === '.md') {
4236
+ content = await marked.parse(raw);
4237
+ } else if (ext === '.html' || ext === '.htm') {
4238
+ content = raw;
4239
+ } else {
4240
+ return { content: [{ type: "text", text: `❌ 対応していないファイル形式です: ${ext} (.md または .html のみ)` }], isError: true };
4241
+ }
4242
+ } catch (e) {
4243
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4244
+ }
4245
+ }
4246
+
4247
+ const postType = args?.post_type || 'post';
4248
+
4249
+ if (postType === 'page' && (args?.categories !== undefined || args?.tags !== undefined)) {
4250
+ return { content: [{ type: "text", text: "❌ 固定ページにカテゴリ・タグは設定できません。" }], isError: true };
4251
+ }
4252
+
4253
+ const postData = {
4254
+ title,
4255
+ status: args?.status || 'draft',
4256
+ post_type: postType,
4257
+ };
4258
+ if (args?.slug !== undefined) postData.slug = args.slug;
4259
+ if (postType === 'page') {
4260
+ if (args?.parent !== undefined) postData.parent = args.parent;
4261
+ } else {
4262
+ if (args?.categories !== undefined) postData.categories = args.categories;
4263
+ if (args?.tags !== undefined) postData.tags = args.tags;
4264
+ }
4265
+ if (args?.excerpt !== undefined) postData.excerpt = args.excerpt;
4266
+ if (args?.featured_media !== undefined) postData.featured_media = args.featured_media;
4267
+ if (content && content.trim()) postData.content = content;
4268
+
4269
+ const typeLabel = postType === 'page' ? '固定ページ' : '投稿';
4270
+
4271
+ try {
4272
+ const created = await client.createPost(postData);
4273
+ const postId = created.id;
4274
+ const editUrl = `${client.wpUrl.replace(/\/$/, '')}/wp-admin/post.php?post=${postId}&action=edit`;
4275
+ const resultText = [
4276
+ `✅ ${typeLabel}を作成しました`,
4277
+ ``,
4278
+ ` ID: ${postId}`,
4279
+ ` タイプ: ${postType}`,
4280
+ ` タイトル: ${created.title?.raw || title}`,
4281
+ ` ステータス: ${created.status}`,
4282
+ ` 編集URL: ${editUrl}`,
4283
+ ].join('\n');
4284
+ return { content: [{ type: "text", text: resultText }] };
4285
+ } catch (e) {
4286
+ return errorResponse(name, `${typeLabel}作成に失敗しました: ${e.message}`, args?.site);
4287
+ }
4288
+ }
4289
+
3815
4290
  case "list_posts": {
3816
4291
  let client;
3817
4292
  try {
@@ -3829,26 +4304,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3829
4304
  return { content: [{ type: "text", text: "❌ slug と search は同時に指定できません。slug(完全一致)または search(キーワード検索)のいずれかを使用してください。" }], isError: true };
3830
4305
  }
3831
4306
 
3832
- // listArgs を構築(slug 指定時: status 未指定なら 'any' に自動設定)
3833
- const listArgs = { ...(args || {}), slug, search };
4307
+ const postType = args?.post_type || 'any';
4308
+ const listArgs = { ...(args || {}), slug, search, post_type: postType };
3834
4309
  if (slug && !listArgs.status) {
3835
4310
  listArgs.status = 'any';
3836
4311
  }
3837
4312
 
3838
- const { data: posts, total, totalPages } = await client.listPosts(listArgs);
4313
+ const result = await client.searchPosts(listArgs);
4314
+ const posts = result.items || [];
4315
+ const total = result.total || 0;
4316
+ const totalPages = result.total_pages || 0;
3839
4317
 
3840
- if (!posts || posts.length === 0) {
3841
- return { content: [{ type: "text", text: "📋 該当する投稿はありません。" }] };
4318
+ if (posts.length === 0) {
4319
+ return { content: [{ type: "text", text: "📋 該当する投稿・固定ページはありません。" }] };
3842
4320
  }
3843
4321
 
3844
4322
  const currentPage = args?.page || 1;
3845
4323
  const list = posts.map(p => {
3846
- return ` [${p.id}] ${p.title?.rendered || '(無題)'}\n` +
4324
+ const typeLabel = p.post_type === 'page' ? '[固定ページ]' : '[投稿]';
4325
+ return ` [${p.id}] ${typeLabel} ${p.title || '(無題)'}\n` +
3847
4326
  ` ステータス: ${p.status} | 更新: ${p.modified?.split('T')[0] || '-'}\n` +
3848
4327
  ` URL: ${p.link || '-'}`;
3849
4328
  }).join('\n\n');
3850
4329
 
3851
- let listText = `📋 投稿一覧 (${posts.length}件 / 全${total}件)\n\n${list}`;
4330
+ let listText = `📋 一覧 (${posts.length}件 / 全${total}件)\n\n${list}`;
3852
4331
  if (totalPages > 1) {
3853
4332
  listText += `\n\n📄 ページ: ${currentPage} / ${totalPages}`;
3854
4333
  if (currentPage < totalPages) {
@@ -3859,6 +4338,259 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3859
4338
  return { content: [{ type: "text", text: listText }] };
3860
4339
  }
3861
4340
 
4341
+ case "create_blog_parts": {
4342
+ let client;
4343
+ try {
4344
+ client = registry.get(args?.site);
4345
+ } catch (e) {
4346
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4347
+ }
4348
+
4349
+ const title = args?.title?.trim();
4350
+ if (!title) {
4351
+ return { content: [{ type: "text", text: "❌ title は必須です。" }], isError: true };
4352
+ }
4353
+
4354
+ let content = args?.content;
4355
+ const filePath = args?.filePath;
4356
+
4357
+ if (content !== undefined && filePath !== undefined) {
4358
+ return { content: [{ type: "text", text: "❌ content と filePath は同時に指定できません。" }], isError: true };
4359
+ }
4360
+
4361
+ if (filePath) {
4362
+ try {
4363
+ const ext = extname(filePath).toLowerCase();
4364
+ const raw = readHTMLFromFile(filePath).html;
4365
+ if (ext === '.md') {
4366
+ content = await marked.parse(raw);
4367
+ } else if (ext === '.html' || ext === '.htm') {
4368
+ content = raw;
4369
+ } else {
4370
+ return { content: [{ type: "text", text: `❌ 対応していないファイル形式です: ${ext} (.md または .html のみ)` }], isError: true };
4371
+ }
4372
+ } catch (e) {
4373
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4374
+ }
4375
+ }
4376
+
4377
+ const postData = {
4378
+ title,
4379
+ status: args?.status || 'draft',
4380
+ post_type: 'blog_parts',
4381
+ };
4382
+ if (args?.slug !== undefined) postData.slug = args.slug;
4383
+ if (args?.parts_use !== undefined) postData.parts_use = args.parts_use;
4384
+ if (content && content.trim()) postData.content = content;
4385
+
4386
+ try {
4387
+ const created = await client.createPost(postData);
4388
+ const postId = created.id;
4389
+ const editUrl = `${client.wpUrl.replace(/\/$/, '')}/wp-admin/post.php?post=${postId}&action=edit`;
4390
+ const resultText = [
4391
+ `✅ ブログパーツを作成しました`,
4392
+ ``,
4393
+ ` ID: ${postId}`,
4394
+ ` タイトル: ${created.title?.raw || title}`,
4395
+ ` ステータス: ${created.status}`,
4396
+ ` 編集URL: ${editUrl}`,
4397
+ ].join('\n');
4398
+ return { content: [{ type: "text", text: resultText }] };
4399
+ } catch (e) {
4400
+ return errorResponse(name, `ブログパーツ作成に失敗しました: ${e.message}`, args?.site);
4401
+ }
4402
+ }
4403
+
4404
+ case "list_blog_parts": {
4405
+ let client;
4406
+ try {
4407
+ client = registry.get(args?.site);
4408
+ } catch (e) {
4409
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4410
+ }
4411
+
4412
+ const bpResult = await client.searchBlogParts(args || {});
4413
+ const bpParts = bpResult.items || [];
4414
+ const bpTotal = bpResult.total || 0;
4415
+ const bpTotalPages = bpResult.total_pages || 0;
4416
+
4417
+ if (bpParts.length === 0) {
4418
+ return { content: [{ type: "text", text: "📋 該当するブログパーツはありません。" }] };
4419
+ }
4420
+
4421
+ const bpPage = args?.page || 1;
4422
+ const bpList = bpParts.map(p => {
4423
+ return ` [${p.id}] ${p.title || '(無題)'}\n ステータス: ${p.status} | 更新: ${p.modified || '-'}`;
4424
+ }).join('\n\n');
4425
+
4426
+ let bpText = `📋 ブログ��ーツ一覧 (${bpParts.length}件 / 全${bpTotal}件)\n\n${bpList}`;
4427
+ if (bpTotalPages > 1) {
4428
+ bpText += `\n\n📄 ページ: ${bpPage} / ${bpTotalPages}`;
4429
+ if (bpPage < bpTotalPages) {
4430
+ bpText += ` (次: page=${bpPage + 1})`;
4431
+ }
4432
+ }
4433
+ return { content: [{ type: "text", text: bpText }] };
4434
+ }
4435
+
4436
+ case "get_blog_parts": {
4437
+ let client;
4438
+ try {
4439
+ client = registry.get(args?.site);
4440
+ } catch (e) {
4441
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4442
+ }
4443
+
4444
+ const bpPartsId = args?.partsId;
4445
+ if (!bpPartsId) {
4446
+ return { content: [{ type: "text", text: "❌ partsId は必須です" }], isError: true };
4447
+ }
4448
+
4449
+ const siteName = args?.site || registry.defaultName();
4450
+ const { full: bpFull, contains: bpContains, section: bpSection, blockType: bpBlockType } = (args || {});
4451
+
4452
+ let bpState;
4453
+ try {
4454
+ bpState = await client.headlessGetStructure(bpPartsId);
4455
+ if (bpFull || bpContains || bpSection || bpBlockType) {
4456
+ const blocksData = await client.headlessGetBlocks(bpPartsId);
4457
+ bpState.allBlocks = blocksData.blocks;
4458
+ }
4459
+ } catch (e) {
4460
+ const formatted = formatHeadlessConflictError(e);
4461
+ if (formatted) return formatted;
4462
+ throw e;
4463
+ }
4464
+
4465
+ // snapshot 作成(編集用 ref を付与)
4466
+ let _bpRefTag = (_idx) => '';
4467
+ let _bpSnapshotLine = '';
4468
+ const bpBlocks = bpState.allBlocks || [];
4469
+ if (bpBlocks.length > 0) {
4470
+ const _bpSnapshotId = generateSnapshotId();
4471
+ const _bpRevision = `rev_${Date.now().toString(36)}`;
4472
+ const _bpSnapshotBlocks = bpBlocks.map((b, i) => ({ ...b, ref: `r${i}` }));
4473
+ snapshotCache.set({
4474
+ snapshotId: _bpSnapshotId, postId: bpPartsId, mode: 'headless',
4475
+ sessionId: null, siteName,
4476
+ createdAt: Date.now(), revision: _bpRevision,
4477
+ blocks: _bpSnapshotBlocks, displayMode: bpFull ? 'full' : 'default',
4478
+ });
4479
+ const _bpRefMap = new Map(_bpSnapshotBlocks.map(b => [b.index, b.ref]));
4480
+ _bpRefTag = (idx) => { const r = _bpRefMap.get(idx); return r ? `|${r}` : ''; };
4481
+ _bpSnapshotLine = `\n[snapshot:${_bpSnapshotId} rev:${_bpRevision}]`;
4482
+ }
4483
+
4484
+ if ((bpFull || bpContains || bpSection || bpBlockType) && bpBlocks.length > 0) {
4485
+ let filteredBlocks = bpBlocks;
4486
+ if (bpBlockType) filteredBlocks = filteredBlocks.filter(b => b.type === bpBlockType);
4487
+ if (bpContains) filteredBlocks = filteredBlocks.filter(b => (b.html || '').includes(bpContains));
4488
+
4489
+ const bpBlockList = filteredBlocks.map(b => {
4490
+ const indent = ' '.repeat((b.depth || 0) + 1);
4491
+ const hasChildren = b.isContainer ? ` ▼ [${b.childCount}子]` : '';
4492
+ return `${indent}[${b.index}${_bpRefTag(b.index)}] ${b.type}${hasChildren}`;
4493
+ }).join('\n');
4494
+
4495
+ return { content: [{ type: "text", text: `📦 ブログパーツ [${bpPartsId}]\n\n全ブロック (${filteredBlocks.length}件):\n\n${bpBlockList}${_bpSnapshotLine}\n[DEBUG] mode=headless` }] };
4496
+ }
4497
+
4498
+ // デフォルト: 見出し + サマリー
4499
+ const bpHeadings = (bpState.headings || [])
4500
+ .map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index})`)
4501
+ .join("\n");
4502
+ const bpSummaryLines = Object.entries(bpState.blockSummary || {})
4503
+ .map(([type, data]) => ` ${type}: ${data.count}個`)
4504
+ .join("\n");
4505
+
4506
+ let bpOutText = `📦 ブログパーツ [${bpPartsId}]\n\n`;
4507
+ if (bpHeadings) bpOutText += `見出し構造:\n${bpHeadings}\n\n`;
4508
+ bpOutText += `ブロック概要:\n${bpSummaryLines}`;
4509
+ bpOutText += _bpSnapshotLine;
4510
+ bpOutText += '\n[DEBUG] mode=headless';
4511
+
4512
+ return { content: [{ type: "text", text: bpOutText }] };
4513
+ }
4514
+
4515
+ case "update_blog_parts": {
4516
+ let client;
4517
+ try {
4518
+ client = registry.get(args?.site);
4519
+ } catch (e) {
4520
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4521
+ }
4522
+
4523
+ const ubpPartsId = args?.partsId;
4524
+ if (!ubpPartsId) {
4525
+ return { content: [{ type: "text", text: "❌ partsId は必須です" }], isError: true };
4526
+ }
4527
+
4528
+ const siteName = args?.site || registry.defaultName();
4529
+
4530
+ // ref/refs → index/indices 解決
4531
+ const ubpParams = { ...args };
4532
+ delete ubpParams.partsId;
4533
+ delete ubpParams.site;
4534
+
4535
+ if (ubpParams.snapshotId && (ubpParams.ref || ubpParams.refs)) {
4536
+ const snap = snapshotCache.get(ubpParams.snapshotId);
4537
+ if (!snap) {
4538
+ return { content: [{ type: "text", text: "❌ snapshotId が無効または期限切れです。get_blog_parts で再取得してください。" }], isError: true };
4539
+ }
4540
+ if (snap.postId !== ubpPartsId) {
4541
+ return { content: [{ type: "text", text: `❌ snapshot の postId (${snap.postId}) と partsId (${ubpPartsId}) が一致しません。` }], isError: true };
4542
+ }
4543
+
4544
+ if (ubpParams.ref) {
4545
+ const refBlock = snap.blocks.find(b => b.ref === ubpParams.ref);
4546
+ if (!refBlock) {
4547
+ return { content: [{ type: "text", text: `❌ ref "${ubpParams.ref}" が snapshot 内に見つかりません。` }], isError: true };
4548
+ }
4549
+ ubpParams.index = refBlock.index;
4550
+ delete ubpParams.ref;
4551
+ }
4552
+ if (ubpParams.refs) {
4553
+ const indices = [];
4554
+ for (const r of ubpParams.refs) {
4555
+ const refBlock = snap.blocks.find(b => b.ref === r);
4556
+ if (!refBlock) {
4557
+ return { content: [{ type: "text", text: `❌ ref "${r}" が snapshot 内に見つかり���せん。` }], isError: true };
4558
+ }
4559
+ indices.push(refBlock.index);
4560
+ }
4561
+ ubpParams.indices = indices;
4562
+ delete ubpParams.refs;
4563
+ }
4564
+ delete ubpParams.snapshotId;
4565
+ }
4566
+ delete ubpParams.expectedRevision;
4567
+
4568
+ try {
4569
+ const ubpResult = await client.headlessUpdate(ubpPartsId, ubpParams);
4570
+
4571
+ // 新 snapshot 生成
4572
+ let ubpSnapshotLine = '';
4573
+ if (ubpResult.blocks && ubpResult.blocks.length > 0) {
4574
+ const _ubpSnapId = generateSnapshotId();
4575
+ const _ubpRev = `rev_${Date.now().toString(36)}`;
4576
+ const _ubpSnapBlocks = ubpResult.blocks.map((b, i) => ({ ...b, ref: `r${i}` }));
4577
+ snapshotCache.set({
4578
+ snapshotId: _ubpSnapId, postId: ubpPartsId, mode: 'headless',
4579
+ sessionId: null, siteName,
4580
+ createdAt: Date.now(), revision: _ubpRev,
4581
+ blocks: _ubpSnapBlocks, displayMode: 'full',
4582
+ });
4583
+ ubpSnapshotLine = `\n[snapshot:${_ubpSnapId} rev:${_ubpRev}]`;
4584
+ }
4585
+
4586
+ return { content: [{ type: "text", text: `✅ ブログパーツ [${ubpPartsId}] を更新しました。${ubpSnapshotLine}` }] };
4587
+ } catch (e) {
4588
+ const formatted = formatHeadlessConflictError(e);
4589
+ if (formatted) return formatted;
4590
+ throw e;
4591
+ }
4592
+ }
4593
+
3862
4594
  case "list_taxonomies": {
3863
4595
  let client;
3864
4596
  try {
@@ -3981,7 +4713,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3981
4713
 
3982
4714
  if (mode === 'headless' && target === 'selected') {
3983
4715
  return {
3984
- content: [{ type: "text", text: "❌ target:'selected' はエディタ接続時のみ使用可能です。index を指定してください。" }],
4716
+ content: [{ type: "text", text: "❌ target:'selected' はエディタ接続時のみ使用可能です。ref+snapshotId, section, blockType, contains 等を指定してください。" }],
3985
4717
  isError: true,
3986
4718
  };
3987
4719
  }
@@ -3991,7 +4723,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3991
4723
  (headingLevel && headingContains);
3992
4724
  if (!hasTarget) {
3993
4725
  return {
3994
- content: [{ type: "text", text: "❌ ターゲットが指定されていません。\ntarget, index, indices, section, blockType, contains 等のいずれかを指定してください。" }],
4726
+ content: [{ type: "text", text: "❌ ターゲットが指定されていません。\nref+snapshotId, section, blockType, contains 等のいずれかを指定してください。" }],
3995
4727
  isError: true,
3996
4728
  };
3997
4729
  }
@@ -4289,8 +5021,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4289
5021
 
4290
5022
  let url;
4291
5023
  if (target === 'front') {
4292
- const postData = await client.wpCoreRequest(`/wp/v2/posts/${postId}?_fields=link`);
4293
- url = postData.link;
5024
+ try {
5025
+ const meta = await client.headlessGetMeta(postId);
5026
+ url = meta.link;
5027
+ } catch {
5028
+ try {
5029
+ const postData = await client.wpCoreRequest(`/wp/v2/posts/${postId}?_fields=link`);
5030
+ url = postData.link;
5031
+ } catch {
5032
+ const pageData = await client.wpCoreRequest(`/wp/v2/pages/${postId}?_fields=link`);
5033
+ url = pageData.link;
5034
+ }
5035
+ }
4294
5036
  } else {
4295
5037
  url = `${client.wpUrl.replace(/\/$/, '')}/wp-admin/post.php?post=${postId}&action=edit`;
4296
5038
  }
@@ -4335,6 +5077,102 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4335
5077
  }
4336
5078
  }
4337
5079
 
5080
+ case "upload_media": {
5081
+ const siteName = args?.site || 'default';
5082
+ let client;
5083
+ try { client = registry.get(siteName); }
5084
+ catch (e) { return errorResponse("upload_media", e.message, siteName); }
5085
+
5086
+ if (!args?.filePath) return errorResponse("upload_media", "filePath is required", siteName);
5087
+ if (!args?.name) return errorResponse("upload_media", "name is required", siteName);
5088
+ if (!args?.alt) return errorResponse("upload_media", "alt is required", siteName);
5089
+
5090
+ let fileInfo;
5091
+ try { fileInfo = validateImageFile(args.filePath); }
5092
+ catch (e) { return errorResponse("upload_media", e.message, siteName); }
5093
+
5094
+ const uploadFilename = `${args.name}${fileInfo.ext}`;
5095
+ const fileBuffer = readFileSync(fileInfo.resolved);
5096
+
5097
+ let media;
5098
+ try {
5099
+ media = await client.uploadMedia(fileBuffer, uploadFilename, fileInfo.contentType);
5100
+ } catch (e) {
5101
+ return errorResponse("upload_media", `Upload failed: ${e.message}`, siteName);
5102
+ }
5103
+
5104
+ const titleText = args?.title || args.alt;
5105
+ try {
5106
+ await client.updateMediaMeta(media.id, { title: titleText, alt_text: args.alt });
5107
+ } catch (e) {
5108
+ // meta 更新失敗は警告のみ(アップロード自体は成功)
5109
+ }
5110
+
5111
+ const url = media.source_url || '';
5112
+ const width = media.media_details?.width || null;
5113
+ const height = media.media_details?.height || null;
5114
+ const text = `✅ Uploaded: ID=${media.id}\n` +
5115
+ ` Title: ${titleText}\n` +
5116
+ ` Alt: ${args.alt}\n` +
5117
+ ` URL: ${url}\n` +
5118
+ (width && height ? ` Size: ${width}x${height}\n` : '') +
5119
+ ` Filename: ${uploadFilename}`;
5120
+ return { content: [{ type: "text", text }] };
5121
+ }
5122
+
5123
+ case "search_media": {
5124
+ const siteName = args?.site || 'default';
5125
+ let client;
5126
+ try { client = registry.get(siteName); }
5127
+ catch (e) { return errorResponse("search_media", e.message, siteName); }
5128
+
5129
+ if (!args?.query) return errorResponse("search_media", "query is required", siteName);
5130
+
5131
+ const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
5132
+ const perPage = args?.per_page || 10;
5133
+
5134
+ const results = await Promise.allSettled(
5135
+ variants.map(v => client.searchMedia(v, perPage))
5136
+ );
5137
+
5138
+ const seenIds = new Set();
5139
+ const hits = [];
5140
+ for (let i = 0; i < results.length; i++) {
5141
+ if (results[i].status !== 'fulfilled') continue;
5142
+ for (const item of results[i].value) {
5143
+ if (seenIds.has(item.id)) continue;
5144
+ seenIds.add(item.id);
5145
+ hits.push({
5146
+ id: item.id,
5147
+ title: item.title?.rendered || '',
5148
+ alt: item.alt_text || '',
5149
+ url: item.source_url || '',
5150
+ width: item.media_details?.width || null,
5151
+ height: item.media_details?.height || null,
5152
+ matched: variants[i],
5153
+ });
5154
+ }
5155
+ }
5156
+
5157
+ let text = `🔍 Search: "${args.query}"\n`;
5158
+ text += `🔄 Variants (${variants.length}): ${variants.join(', ')}\n\n`;
5159
+
5160
+ if (hits.length === 0) {
5161
+ text += '❌ No results found.';
5162
+ } else {
5163
+ text += `📷 ${hits.length} result(s):\n\n`;
5164
+ for (const h of hits) {
5165
+ text += ` [ID: ${h.id}] ${h.title}\n`;
5166
+ text += ` Alt: ${h.alt}\n`;
5167
+ text += ` URL: ${h.url}\n`;
5168
+ if (h.width && h.height) text += ` Size: ${h.width}x${h.height}\n`;
5169
+ text += ` Matched: "${h.matched}"\n\n`;
5170
+ }
5171
+ }
5172
+
5173
+ return { content: [{ type: "text", text }] };
5174
+ }
5175
+
4338
5176
  case "list_connections": {
4339
5177
  const conns = registry.list();
4340
5178
  const text = conns.map(c => {
@@ -4375,6 +5213,101 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4375
5213
  return { content: [{ type: "text", text: `✅ フィードバックを送信しました` }] };
4376
5214
  }
4377
5215
 
5216
+ case "search_asp_link": {
5217
+ let client;
5218
+ try {
5219
+ client = registry.get(args?.site);
5220
+ } catch (e) {
5221
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
5222
+ }
5223
+
5224
+ const slug = args?.slug?.trim() || undefined;
5225
+ const search = args?.search?.trim() || undefined;
5226
+
5227
+ if (slug && search) {
5228
+ return { content: [{ type: "text", text: "❌ slug と search は同時に指定できません。どちらか一方を使用してください。" }], isError: true };
5229
+ }
5230
+
5231
+ let result;
5232
+ try {
5233
+ result = await client.searchAspLinks({
5234
+ search,
5235
+ slug,
5236
+ per_page: args?.per_page,
5237
+ page: args?.page,
5238
+ });
5239
+ } catch (e) {
5240
+ return { content: [{ type: "text", text: `❌ ASPリンク検索エラー: ${e.message}` }], isError: true };
5241
+ }
5242
+
5243
+ const items = result.items || [];
5244
+ if (items.length === 0) {
5245
+ return { content: [{ type: "text", text: "🔗 該当するASPリンクはありません。" }] };
5246
+ }
5247
+
5248
+ const list = items.map(item => {
5249
+ const lines = [
5250
+ ` [${item.id}] ${item.title}`,
5251
+ ` 案件ID: ${item.data_id} | slug: ${item.slug}`,
5252
+ ];
5253
+ if (item.asp_url) lines.push(` ASP URL: ${item.asp_url}`);
5254
+ if (item.asp_url_sp) lines.push(` ASP URL(B): ${item.asp_url_sp}`);
5255
+ if (item.direct_url) lines.push(` 直リンク: ${item.direct_url}`);
5256
+ lines.push(` tag: ${item.tag}`);
5257
+ return lines.join('\n');
5258
+ }).join('\n\n');
5259
+
5260
+ let text = `🔗 ASPリンク検索結果 (${items.length}件 / 全${result.total}件)\n\n${list}`;
5261
+ if (result.total_pages > 1) {
5262
+ text += `\n\n📄 ページ: ${result.page} / ${result.total_pages}`;
5263
+ }
5264
+
5265
+ return { content: [{ type: "text", text }] };
5266
+ }
5267
+
5268
+ case "register_asp_link": {
5269
+ let client;
5270
+ try {
5271
+ client = registry.get(args?.site);
5272
+ } catch (e) {
5273
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
5274
+ }
5275
+
5276
+ if (!args?.title || !args?.slug || !args?.asp_url) {
5277
+ return { content: [{ type: "text", text: "❌ title, slug, asp_url は必須です。" }], isError: true };
5278
+ }
5279
+
5280
+ let result;
5281
+ try {
5282
+ result = await client.createAspLink({
5283
+ title: args.title,
5284
+ slug: args.slug,
5285
+ asp_id: args?.asp_id,
5286
+ asp_name: args?.asp_name,
5287
+ asp_url: args.asp_url,
5288
+ asp_url_sp: args?.asp_url_sp,
5289
+ direct_url: args?.direct_url,
5290
+ });
5291
+ } catch (e) {
5292
+ return { content: [{ type: "text", text: `❌ ASPリンク登録エラー: ${e.message}` }], isError: true };
5293
+ }
5294
+
5295
+ const lines = [
5296
+ `✅ ASPリンク登録完了`,
5297
+ ``,
5298
+ ` ID: ${result.id}`,
5299
+ ` タイトル: ${result.title}`,
5300
+ ` slug: ${result.slug}`,
5301
+ ` 案件ID: ${result.data_id}`,
5302
+ ];
5303
+ if (result.asp_url) lines.push(` ASP URL: ${result.asp_url}`);
5304
+ if (result.asp_url_sp) lines.push(` ASP URL(B): ${result.asp_url_sp}`);
5305
+ if (result.direct_url) lines.push(` 直リンク: ${result.direct_url}`);
5306
+ lines.push(` tag: ${result.tag}`);
5307
+
5308
+ return { content: [{ type: "text", text: lines.join('\n') }] };
5309
+ }
5310
+
4378
5311
  default:
4379
5312
  throw new Error(`Unknown tool: ${name}`);
4380
5313
  } })();